diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 7b056c3a..1d654f5a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -4,7 +4,7 @@ reviews: # Use "! " at the start of a pattern to EXCLUDE it from the review - "!**/doc/api/**" # Ignore ONLY the generated TypeDoc API documentation - "!**/docs/**" # Ignore other generated documentation artifacts - - "!**/.vitepress/cache/**" # Ignore VitePress cache - - "!**/.vitepress/dist/**" # Ignore VitePress build output + - "!**/.vitepress/cache/**" # Ignore VitePress cache + - "!**/.vitepress/dist/**" # Ignore VitePress build output - "!**/dist/**" # Ignore all build artifacts - "!**/vendor/**" # Ignore third-party code diff --git a/package.json b/package.json index c0e1f469..a1eeb23d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.2.6", + "version": "2.3.0", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/package.json b/packages/library/package.json index ae3106d0..73526c11 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.2.6", + "version": "2.3.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 7d61d1c2..64bcfd40 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -25,7 +25,7 @@ declare module '#library/type.library.js' { export class Pledge { #pledge: PromiseWithResolvers; #status = {} as Pledge.Status; - static #dbg = new Logify('Pledge: '); + static #dbg = new Logify('Pledge'); static #static = {} as Pledge.Constructor; static STATE = secure({ diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 646da620..c6cc0981 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -25,6 +25,7 @@ export default defineConfig({ text: 'Getting Started', items: [ { text: 'Introduction', link: '/README' }, + { text: 'Installation', link: '/doc/installation' }, { text: 'Cookbook', link: '/doc/tempo.cookbook' }, { text: 'Migration Guide', link: '/doc/migration-guide' }, { text: 'Release Notes', link: '/doc/releases/' } @@ -34,6 +35,7 @@ export default defineConfig({ text: 'Core Concepts', items: [ { text: 'Configuration', link: '/doc/tempo.config' }, + { text: 'Smart Parsing', link: '/doc/tempo.parse' }, { text: 'Modularity', link: '/doc/tempo.modularity' }, { text: 'Layout Patterns', link: '/doc/tempo.layout' }, { text: 'Terms System', link: '/doc/tempo.term' }, diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index 8aabb47c..bf4fa258 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.3.0] - 2026-04-22 + +### Added +- **Standalone Parse Support**: Enhanced the `ParseModule` to support standalone parsing of textual dates (including names like "Jan") without requiring a bound host class instance. +- **Backtracking Security**: Implemented suspicious quantifier detection (`Match.backtrack`) in the snippet registry to prevent catastrophic backtracking and malicious regex patterns. +- **Automatic Sphere Sync**: The engine now automatically recalculates the `config.sphere` (hemisphere) state whenever the `timeZone` is updated in the configuration. + +### Changed +- **Heading Hierarchy**: Restructured the documentation and README headers to use a sequential H2-based hierarchy for improved accessibility and document flow. + +### Fixed +- **Infinite Loop Protection**: Added safety-valve logic to the term resolution engine to prevent infinite loops when traversing large date ranges. +- **Parse Error Resilience**: Hardened the resolution engine to explicitly detect and log `undefined` results from the parser, ensuring `isValid` correctly reflects the parse state and preventing silent UTC fallbacks. +- **Standalone Resilience**: Added optional chaining to all host class references in the term resolver to prevent `TypeError` in standalone contexts. +- **Type-Safe Configuration**: Updated the `Options` type to strictly isolate parse-time-only properties from runtime state. + ## [2.2.6] - 2026-04-20 ### Added diff --git a/packages/tempo/README.md b/packages/tempo/README.md index 5a1c0a4b..aee8c3bd 100644 --- a/packages/tempo/README.md +++ b/packages/tempo/README.md @@ -1,23 +1,23 @@ - +
- -
- Tempo logo + + Tempo Logo -

Tempo

-

The Professional Date-Time Library for the Temporal API

+
+

Tempo

+

The Professional Date-Time Library for the Temporal API

-
-**Tempo** is a premium, high-performance wrapper around the JavaScript `Temporal` API. It provides a modern, **immutable**, and **fluent** interface for date-time manipulation, and flexible parsing. It's designed as a better-performing, type-safe alternative to legacy libraries like **Moment.js**, **Day.js**, and **Luxon**. +**Tempo** is a premium, high-performance wrapper for the ECMAScript `Temporal` API. Designed for professionals, it combines **immutable** state-management with a **fluent**, natural-language engine. It is the modern, type-safe successor to legacy libraries like Moment.js and Luxon. -
- + + +
@@ -28,121 +28,72 @@
License: MIT
-
- -## ๐Ÿš€ Overview -Working with `Date` in JavaScript has historically been painful. The new `Temporal` standard (Stage 4) fixes this, but it can be verbose and strict when parsing strings. - -**Tempo** bridges that gap by providing: -- **Flexible Parsing**: Interprets almost any date string, including relative ones like "next Friday". -- **Natural Language**: Supports word-based numbers (0-10) in relative parsing (e.g., "two days ago"). -- **Fluent API**: Chainable methods for adding, subtracting, and setting date-times (similar to Moment.js). -- **Formatting**: Use custom tokens to format date-times in a way that is both intuitive and flexible. -- **Plugin**: Extend core functionality safely; all extensions (including the Ticker) are opted-into via side-effect imports or explicit registration, ensuring a lean footprint even in the full package. -- **Terms**: Access complex date ranges (Quarters, Seasons, Zodiacs) easily. -- **Immutable**: Operations (like `set` and `add`) return a new `Tempo` instance, ensuring thread safety and predictability. -## ๐Ÿค” Why Tempo? -If you're looking for a **modern date library** that leverages the native power of the browser's `Temporal` API, Tempo is for you. - -- **Type Safety**: Built from the ground up with TypeScript. -- **Performance**: High-performance wrapper with minimal overhead. -- **Familiarity**: If you like the fluent syntax of **Moment** or **Day.js**, you'll feel right at home. -- **Future-Proof**: Built on the TC39 `Temporal` standard. -## ๐ŸŽฏ Target Audience - -Tempo is built for **modern JavaScript developers** who require a premium, type-safe, and developer-friendly interface over the native Temporal API. It is ideal for those migrating from legacy libraries like **Moment.js**, **Day.js**, or **Luxon**, as well as teams building complex, time-sensitive applications that demand reliability, immutability, and high-performance parsing. -Tempo is designed for a broad spectrum of developers and teams who interact with date and time data in JavaScript: - -### 1. Modern JavaScript Developers -For those who want to leverage the power of the native `Temporal` API today but find its raw implementation too verbose or strict for rapid development. - -### 2. Teams Migrating from Legacy Libraries -Ideal for organizations looking to move away from **Moment.js**, **Day.js**, or **Luxon** without sacrificing the fluent, chainable API and flexible parsing on which they've come to rely. - -### 3. Enterprise Application Architects -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 -``` -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. +--- +## โšก Quick Start ```javascript import { Tempo } from '@magmacomputing/tempo'; -const t = new Tempo('next Friday'); -console.log(t.format('{dd} {mon} {yyyy}')); +// ๐ŸŽฏ Natural Language Parsing +const event = new Tempo('next Friday 3pm'); + +// ๐Ÿ”„ Fluent Mutations (Immutable) +const reminder = event.add({ hours: 2 }).set({ minute: 0 }); + +// โณ Comparative Durations +const diff = event.until('next month'); +console.log(diff.toString()); // e.g. P3W2D + +// ๐Ÿ“ Beautiful Formatting +console.log(event.format('{mon} {day}, {year}')); // e.g. Oct 24, 2026 +``` + +--- + +## ๐Ÿ“ฆ Installation + +```bash +npm install @magmacomputing/tempo # npm +yarn add @magmacomputing/tempo # yarn +pnpm add @magmacomputing/tempo # pnpm +bun add @magmacomputing/tempo # bun +deno add npm:@magmacomputing/tempo # deno ``` -### ๐ŸŒ in the Browser (Import Maps) -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. +
+๐ŸŒ Browser & Lite Environments +For modern browsers using **Import Maps**: ```html - ``` -### ๐Ÿ“ฆ in the Browser (Script Tag) -For environments without `importmap` support or simple prototypes, use the global bundle. This automatically attaches the `Tempo` class to the `window` object. - +For rapid prototyping without a package manager (UMD): ```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. +For granular "Lite" builds, see the [Full Installation Guide](https://magmacomputing.github.io/magma/doc/installation). +
-```html - - -``` +--- -> [!TIP] -> **CDN Versioning**: The examples above use pinned versions (`@magmacomputing/tempo@2`, `@magmacomputing/library@2`, `@js-temporal/polyfill@0.5`) for production stability. To use the latest releases, you can omit the version string from every URL (e.g., remove `@2` from all Magma entries and `@0.5` from the polyfill). Ensure all `@magmacomputing/...` entries resolve to the same release to avoid mixed-version loading. +## โœจ Why Tempo? +* **๐Ÿ—๏ธ Future Standard**: Built natively on the TC39 `Temporal` proposal. Inherit the reliability of the future standard. +* **๐Ÿ—ฃ๏ธ Natural Language**: Resolve complex terms like `#friday.last` or "two days ago" with zero configuration. +* **๐Ÿ”„ Cycle Persistence**: Shift by semantic terms (Quarters, Seasons) while preserving your relative day-of-period offset. +* **โšก Zero-Cost Parsing**: Lazy evaluation and smart matching ensure instantiation overhead is near-zero. +* **๐Ÿ›ก๏ธ Monorepo Resilient**: Built for stability in complex environments with proxy-protected registries. +* **๐Ÿ“ฆ Tree-Shakable**: Keep your bundle light. Only load what you needโ€”from Fiscal calendars to high-performance Tickers. --- @@ -154,59 +105,23 @@ For a deeper dive into the API, architecture, and advanced features: * **[Full API Reference Guide](https://magmacomputing.github.io/magma/doc/tempo.api)** โ€” Detailed technical documentation for every class and method. --- -## ๐Ÿ› ๏ธ Quick Start - -```javascript -import { Tempo } from '@magmacomputing/tempo'; - -// Instantiate -const now = new Tempo(); -const birthday = new Tempo('20-May-1990'); -const nextWeek = new Tempo('next Monday'); - -// Manipulate -const later = now.add({ days: 3, hours: 2 }); -const startOfMonth = now.set({ start: 'month' }); - -// Format -console.log(now.format('{dd} {mmm} {yyyy}')); // using custom format with tokens: "24 Jan 2026" -console.log(now.fmt.date); // using pre-built formats: "2026-01-24" -``` - ## ๐Ÿ’ฌ Contact & Support -If you have a question, find a bug, or want to suggest a new feature: - 1. **Bug Reports & Features**: Please open an [Issue](https://github.com/magmacomputing/magma/issues). 2. **Questions & Ideas**: Start a thread in [Discussions](https://github.com/magmacomputing/magma/discussions). 3. **Direct Contact**: You can reach me at `hello@magmacomputing.com.au`. -## ๐Ÿ›ก๏ธ Privacy & Transparency - -We value your privacy. **Tempo** does not include any runtime telemetry or "phone-home" features. -Tempo will never make network requests from your application. +--- ## ๐Ÿ—ณ๏ธ Feedback & Reactions -How are we doing? Let us know with a simple reaction! -*(This will open a pre-filled GitHub Issue)* - [๐Ÿš€ Premium!](https://github.com/magmacomputing/magma/issues/new?title=Feedback:%20๐Ÿš€%20Premium!)   |   [โญ Loving it!](https://github.com/magmacomputing/magma/issues/new?title=Feedback:%20โญ%20Loving%20it!)   |   [๐Ÿ’ก Needs work](https://github.com/magmacomputing/magma/issues/new?title=Feedback:%20๐Ÿ’ก%20Needs%20work)   |   [๐Ÿž Found a bug](https://github.com/magmacomputing/magma/issues/new?title=Feedback:%20๐Ÿž%20Found%20a%20bug) -### โšก Quick Reactions -*(Native reactions available in [Discussions](https://github.com/magmacomputing/magma/discussions/categories/feedback))* - -[๐Ÿ‘ Like](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[โค๏ธ Love](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[๐Ÿ˜„ Haha](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[๐Ÿ˜ฎ Wow](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[๐Ÿ˜ข Sad](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[๐Ÿ˜ก Angry](https://github.com/magmacomputing/magma/discussions/categories/feedback)   |   -[๐Ÿ’ฉ Poop](https://github.com/magmacomputing/magma/discussions/categories/feedback) +--- ## โš–๏ธ License diff --git a/packages/tempo/bin/core.ts b/packages/tempo/bin/core.ts index ce1255da..f73b12d4 100644 --- a/packages/tempo/bin/core.ts +++ b/packages/tempo/bin/core.ts @@ -17,6 +17,7 @@ const resetIdle = () => { console.warn('\n\x1b[33m[Tempo] REPL idle for 1 hour. Safety shutdown triggered.\x1b[0m'); process.exit(0); }, 3600 * 1000); + idleTimer.unref(); }; process.stdin.on('data', resetIdle); diff --git a/packages/tempo/bin/parse.ts b/packages/tempo/bin/parse.ts new file mode 100644 index 00000000..57e4b9c4 --- /dev/null +++ b/packages/tempo/bin/parse.ts @@ -0,0 +1,23 @@ +import { parse } from '#tempo/parse'; + +// Pre-load core symbols (parse) to the global scope +Object.assign(globalThis, { parse }); + +console.log(`\n\x1b[38;2;252;194;1m\x1b[1m โณ Tempo (parse) \x1b[0m\x1b[38;2;45;212;191mREPL initialized (parse only).\x1b[0m\n`); + +/** + * ๐Ÿ’ก SMART IDLE: Auto-exit after 1 hour of keyboard inactivity + * Monitors 'stdin' so background Tickers won't keep the session alive if you walk away. + */ +let idleTimer: NodeJS.Timeout; +const resetIdle = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + console.warn('\n\x1b[33m[Tempo] REPL idle for 1 hour. Safety shutdown triggered.\x1b[0m'); + process.exit(0); + }, 3600 * 1000); + idleTimer.unref(); +}; + +process.stdin.on('data', resetIdle); +resetIdle(); diff --git a/packages/tempo/bin/repl.ts b/packages/tempo/bin/repl.ts index 66a04f8f..462c464e 100644 --- a/packages/tempo/bin/repl.ts +++ b/packages/tempo/bin/repl.ts @@ -18,6 +18,7 @@ const resetIdle = () => { console.warn('\n\x1b[33m[Tempo] REPL idle for 1 hour. Safety shutdown triggered.\x1b[0m'); process.exit(0); }, 3600 * 1000); + idleTimer.unref(); }; process.stdin.on('data', resetIdle); diff --git a/packages/tempo/demo/3-modular-granular.html b/packages/tempo/demo/3-modular-granular.html index e555e1b8..514cb821 100644 --- a/packages/tempo/demo/3-modular-granular.html +++ b/packages/tempo/demo/3-modular-granular.html @@ -23,7 +23,7 @@

โณ Demo 3: Modular (Granular)

diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md new file mode 100644 index 00000000..318d1031 --- /dev/null +++ b/packages/tempo/doc/installation.md @@ -0,0 +1,117 @@ +# Installation Guide + +Tempo is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, Tempo provides a specific path for you. + +--- + +## ๐Ÿ’ป Server & Bundlers (Node.js, Bun, Vite) + +For most modern projects using a package manager, install Tempo via the npm registry. + +```bash +npm install @magmacomputing/tempo # npm +yarn add @magmacomputing/tempo # yarn +pnpm add @magmacomputing/tempo # pnpm +bun add @magmacomputing/tempo # bun +``` + +### Usage +```javascript +import { Tempo } from '@magmacomputing/tempo'; +const t = new Tempo('next Friday'); +``` + +--- + +## ๐Ÿฆ• Deno + +Tempo is a native ESM package and works perfectly with Deno. You can add it via the `deno add` command which will resolve it from the npm registry. + +```bash +deno add npm:@magmacomputing/tempo +``` + +### Usage +```javascript +import { Tempo } from "@magmacomputing/tempo"; +const t = new Tempo(); +``` + +--- + +## ๐ŸŒ Browser (Modern ESM) + +For browser environments that support **Import Maps**, you can use the granular ESM modules. This is the recommended way to use Tempo in the browser as it allows for better caching and modularity. + +### 1. Import Map Setup +Add this to your `` to resolve the dependencies: + +```html + +``` + +### 2. Implementation +```html + +``` + +--- + +## ๐Ÿ“ฆ Browser (Legacy / Global Bundle) + +If you aren't using ESM or just want a simple ` + + + + + +``` + +--- + +## ๐Ÿงช Granular "Lite" Builds (Advanced) + +If you are extremely concerned about bundle size, you can bypass the "Batteries Included" entry point and import only the core engine. You then manually opt-in to the modules you need. + +```javascript +import { Tempo } from '@magmacomputing/tempo/core'; +import { MutateModule } from '@magmacomputing/tempo/mutate'; + +// Opt-in to specific functionality +Tempo.extend(MutateModule); + +const t = new Tempo().add({ days: 1 }); +``` + +> [!IMPORTANT] +> When using the Lite build, the `Tempo` class will have almost no methods (like `.add()`, `.set()`, or `.format()`) until you explicitly call `Tempo.extend()` with the appropriate module. + +--- + +## ๐Ÿ›ก๏ธ Versioning Policy + +We recommend pinning your versions in production environments to ensure stability. + +* **JSDelivr**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo@2/...` (Locks to major version 2) +* **Latest**: `https://cdn.jsdelivr.net/npm/@magmacomputing/tempo/...` (Omit the version string to always receive the latest release. Note that JSDelivr will resolve a missing version tag to the latest published release). diff --git a/packages/tempo/doc/releases/v2.x.md b/packages/tempo/doc/releases/v2.x.md index 1dedb8ac..566f6150 100644 --- a/packages/tempo/doc/releases/v2.x.md +++ b/packages/tempo/doc/releases/v2.x.md @@ -1,5 +1,22 @@ # ๐Ÿ“œ Version 2.x History +## [v2.3.0] - 2026-04-22 +### ๐Ÿงฉ Parsing Innovations +- **Standalone Logic**: Extracted the natural language engine into a dedicated `parse()` function, enabling independent datetime string resolution without requiring a full Tempo instance. +- **Noise Filtering**: Introduced a configurable `ignore` option to strip "noise words" during parsing, significantly increasing the robustness of the natural language interpreter. +- **Backtracking Security**: Implemented a centralized regex safety guard (`Match.backtrack`) in the snippet registry to block potentially malicious patterns and prevent catastrophic backtracking. + +### ๐Ÿ“š Documentation & UX +- **Ecosystem Installation**: Released a comprehensive installation guide covering Node.js (npm/pnpm/yarn), Deno, standard browser ESM, and specialized "Lite" build configurations. +- **Interactive Demo**: Enhanced the browser-based playground with a live timezone selector and real-time clock updates to demonstrate Temporal integration. +- **Quick Start Refinement**: Streamlined the README and configuration documentation to highlight simplified entry points and noise-filtering capabilities. + +### ๐Ÿ› ๏ธ Refactor & Type Safety +- **State Management**: Optimized the internal parser state machine to reduce memory pressure and improve resolution speed for complex strings. +- **Type Safety**: Hardened the TypeScript definitions for all `parse` and `term` resolution functions, ensuring strict type-checking without consumer-side casting. +- **Automatic Context Sync**: The engine now automatically synchronizes the hemisphere (sphere) configuration whenever the timezone is updated. +- **Resilience**: Hardened the resolution loop with safety-valves to prevent infinite loops during extreme date-range traversals and improved parse-error detection to prevent silent state corruption. + ## [v2.2.6] - 2026-04-20 ### ๐Ÿš‘ Emergency Hotfix - **Isomorphic Export Architecture**: Refactored `tempo.entry.ts` and Rollup configuration to ensure the browser ESM bundle exports `Tempo` as both a default and a named export. This guarantees that `import { Tempo } from '@magmacomputing/tempo'` functions identically across Node.js and browser environments without throwing resolution errors. @@ -26,22 +43,19 @@ - **ESM Integrity**: Bundled `tslib` into granular ESM builds to resolve resolution failures in standard browser environments. ## [v2.2.3] - 2026-04-19 -### 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 +### โœจ New Features +- **Modular Parsing**: Initial decoupling of the parse engine to support standalone usage. +- **Layout Snippets**: Introduced pattern-based layout customization for granular formatting control. +- **Project Governance**: Integrated the MIT license and established a dedicated release history. -- Reorganized docs navigation with Cookbook, Advanced Reference, Utility Library, Services & Support -- Expanded API/constructor docs and custom-enum guidance -### Chores +### ๐Ÿ› Bug Fixes +- **Match Propagation**: Ensured parse results correctly propagate through instance clones. +- **Drift Resolution**: Corrected timezone and calendar drift during intensive mutation cycles. -- Improved module-loading diagnostics and general doc formatting cleanup +### ๐Ÿ“š Documentation & Chores +- **Knowledge Architecture**: Reorganized navigation into focused tracks: Cookbook, Advanced Reference, and Utility Library. +- **API Reference**: Expanded constructor documentation and added guidance for custom enum implementations. +- **Diagnostics**: Improved internal module-loading error messages for easier troubleshooting. ## [v2.2.2] - 2026-04-18 ### ๐Ÿ›ก๏ธ Registry Stabilization @@ -80,20 +94,18 @@ - Achieved **100% Test Pass Rate** (384/384) across all environments and distribution bundles. ## [v2.1.3] - 2026-04-18 -### New Features -Added VitePress-based documentation system with TypeDoc API reference integration -Introduced browser demo pages showcasing various loading patterns -Expanded Temporal utilities with nanosecond-precision support and helper functions - -### Improvements -Refactored plugin system to object-based API for improved extensibility -Enhanced memoization performance and caching mechanisms -Streamlined library exports for better modularity and tree-shaking - -### Documentation -New CONTRIBUTING.md guide for developers -Added release process documentation -Updated README with improved formatting +### ๐Ÿ“š Documentation & Ecosystem +- **VitePress Migration**: Launched a modern documentation system with integrated TypeDoc API references. +- **Loading Patterns**: Introduced interactive browser demos for ESM, IIFE, and granular module resolution. +- **Standardized README**: Refreshed the main project entry point with improved formatting and developer guides. + +### ๐Ÿš€ Performance & Extensibility +- **Plugin Refactor**: Migrated the plugin system to a flexible object-based API for better extensibility. +- **Cache Optimization**: Enhanced memoization performance across the core engine. +- **Modular Exports**: Streamlined library entry points to improve tree-shaking and reduce bundle sizes. + +### โฑ๏ธ Temporal Utilities +- **High Precision**: Expanded Temporal utility functions with native support for nanosecond-precision calculations. ## [v2.1.2] - 2026-04-16 Tempo v2.1.2 is a major milestone, delivering a more reactive architecture and rock-solid stability. diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 68b41fa6..7d8d6db6 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -103,6 +103,7 @@ Tempo.init({ | `catch` | `boolean` | `false` | If true, invalid inputs return a Void instance instead of throwing. | | `mode` | `'auto' \| 'strict' \| 'defer'` | `'auto'` | Controls the hydration strategy (e.g., `defer` for Zero-Cost creation). | | `silent` | `boolean` | `false` | Suppresses console output. Combined with `catch: true` for silent failover. | +| `ignore` | `string \| string[]` | `['at']` | List of noise words to be stripped before parsing. | --- @@ -117,9 +118,14 @@ const t = new Tempo('now', { timeZone: 'UTC' }); --- -## 5. Advanced: Parsing Rules +## 5. Advanced Parsing Rules + +Beyond basic settings, Tempo's parsing engine can be extended with custom rules and behaviors to handle specialized natural language or high-volume processing requirements. + +### ๐Ÿ“… 5.1 Custom Events and Periods + +You can extend Tempo's intelligence by supplying custom **Events** (date aliases) and **Periods** (time aliases) at any global configuration tier. -Beyond basic settings, you can extend Tempo's intelligence by supplying custom **Events** (date aliases) and **Periods** (time aliases) at any global configuration tier. ```javascript Tempo.init({ @@ -136,7 +142,7 @@ Tempo.init({ const delivery = new Tempo('deadline'); // Parsed using your custom logic ``` -### โšก 5b. Deferring Initialization (`mode: 'defer'`) +### โšก 5.2 Deferring Initialization (`mode: 'defer'`) By default (`mode: 'auto'`), Tempo uses the **Master Guard** to determine if a string can be lazily evaluated. For exceptionally high-volume scenarios where you may be creating thousands of Tempo instances but only using them for calculations (not formatting or terms), you can force a standard lazy behavior using `mode: 'defer'`. @@ -153,6 +159,31 @@ console.log(t.format('{yyyy}')); // Discovery triggers NOW, only once. > [!TIP] > **Zero-Cost Constructor**: Combining the **Master Guard** (automatic) and the **`defer`** mode allows Tempo to satisfy the "Zero-Cost Constructor" requirement for mass-processing applications. + +### ๐Ÿงน 5.3 Noise Word Filtering (`ignore`) + +Tempo allows you to specify "noise words" that should be ignored during natural language parsing. This is particularly useful for handling human-readable strings that contain connectors or filler words. + +By default, Tempo ignores the word **"at"** (e.g., `"Friday at 3pm"` becomes `"Friday 3pm"` internally). + +```javascript +// Extend globally via Tempo.init() +// This adds 'the' and 'o-clock' to the existing default list (['at']) +Tempo.init({ ignore: ['the', 'o-clock'] }); + +// Use in a specific instance via the Tempo constructor (new Tempo(...)) +// This instance will ignore 'at', 'the', and 'o-clock' +const t = new Tempo('next Friday at 3 o-clock', { + ignore: 'o-clock' +}); + +console.log(t.toString()); // Resolved correctly (noise words stripped) +``` + +> [!TIP] +> **Registry Structure**: The `ignore` registry accepts a **String** or an **Array** of strings. These are converted to a high-performance internal format to support efficient prototype-based shadowing. Note that values provided via `Tempo.init()` or the `new Tempo()` constructor **merge** with the default ignore list rather than replacing it. + + --- ## ๐Ÿ“Š Summary of Tiers diff --git a/packages/tempo/doc/tempo.enumerators.md b/packages/tempo/doc/tempo.enumerators.md index 31e3bee2..16b133da 100644 --- a/packages/tempo/doc/tempo.enumerators.md +++ b/packages/tempo/doc/tempo.enumerators.md @@ -30,11 +30,17 @@ After defining the enumify object, simple TypeScript helper aliases pull out the ```typescript export type SEASON = ValueOf; // Type: 'summer' | 'autumn' | 'winter' | 'spring' +export type Season = KeyOf; // Type: 'Summer' | 'Autumn' | 'Winter' | 'Spring' ``` +The above types allow for the use of `Season` and `SEASON` as type arguments, providing type safety for both the keys and values of the enum. + +It is generally recommended to use the **values** (lowercase) as type arguments, as they are used as the actual values in the enumify object. This allows for easier use of the enumify methods, which operate on the values. + +**Note:** TypeScript provides automatic typing for native enums, but it is not the case with enumify. ## 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`. +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' (tempo.index.ts) export `enums`. You can use the values directly as arguments: diff --git a/packages/tempo/doc/tempo.modularity.md b/packages/tempo/doc/tempo.modularity.md index c3d55baa..c0821dc5 100644 --- a/packages/tempo/doc/tempo.modularity.md +++ b/packages/tempo/doc/tempo.modularity.md @@ -4,7 +4,7 @@ Tempo is designed as a modular library, allowing you to include only the feature ## Core vs. Full -* **@magmacomputing/tempo/core**: The bare-bones Tempo engine. Includes parsing, basic getters, and internal state management. +* **@magmacomputing/tempo/core**: The bare-bones Tempo engine. Includes parsing (standard ISO string or a native `Temporal` object), basic getters, and internal state management. * **@magmacomputing/tempo**: The "batteries included" version. Includes all standard modules (Duration, Format, Term Registry, Mutate, etc.). ```typescript diff --git a/packages/tempo/doc/tempo.parse.md b/packages/tempo/doc/tempo.parse.md new file mode 100644 index 00000000..156ed34e --- /dev/null +++ b/packages/tempo/doc/tempo.parse.md @@ -0,0 +1,95 @@ +# Smart Parsing Guide + +Tempo's strongest feature is its flexible, natural-language parsing engine. While native `Temporal` is strictly ISO-only, Tempo can interpret a vast range of human-friendly date and time expressions. + +## ๐Ÿš€ Standalone Parsing (Zero-Overhead) + +If you only need Tempo's "Smart" parsing but want to keep your project lightweight, you can use the standalone `parse` function. This returns a native `Temporal.ZonedDateTime` without importing the full `Tempo` class. + +```typescript +import { parse } from '@magmacomputing/tempo/parse'; + +// Returns a native Temporal.ZonedDateTime +const zdt = parse('next Tuesday at 3pm', { timeZone: 'Australia/Sydney' }); + +console.log(zdt.toString()); // 2026-04-28T15:00:00+10:00[Australia/Sydney] +``` + +### Why use Standalone Parse? +* **Tree-Shaking**: Your bundler can skip the entire `Tempo` class and its associated methods, significantly reducing your bundle size. +* **Temporal Native**: Perfect for projects that already use native `Temporal` objects but need a friendlier input layer for users. +* **Strict by Default**: The standalone function defaults to `mode: 'strict'`, ensuring that it won't "guess" if the input is ambiguous. + +--- + +## ๐Ÿ—๏ธ Class-Based Parsing + +When using the `Tempo` class, parsing is handled automatically by the constructor. + +```typescript +import { Tempo } from '@magmacomputing/tempo'; + +const t = new Tempo('2 days ago'); +``` + +### Supported Formats +The engine can interpret: +* **ISO Strings**: `2024-05-20T10:00:00Z` +* **Short Dates**: `20-May`, `May 20` (locale-aware) +* **Relative Strings**: `next Monday`, `last Friday`, `2 days ago` +* **Numbers/BigInt**: Unix timestamps in milliseconds or nanoseconds. +* **Temporal Objects**: `ZonedDateTime`, `PlainDate`, `PlainDateTime`. + +--- + +## ๐Ÿงฉ Modularity: Core vs. Full + +The parsing engine is modular. Depending on which version of Tempo you are using, you may need to explicitly enable it: + +| Version | Smart Parsing Status | +| :--- | :--- | +| **Tempo Full** | **Built-in**. Works out of the box. | +| **Tempo Core** | **Opt-in**. You must call `Tempo.extend(ParseModule)` to enable natural language support. | + +### Enabling Smart Parsing in Core +If you are using `@magmacomputing/tempo/core`, the constructor only supports basic ISO strings by default. To enable "next Tuesday" style parsing, you must extend it: + +```typescript +import { Tempo } from '@magmacomputing/tempo/core'; +import { ParseModule } from '@magmacomputing/tempo/parse'; + +Tempo.extend(ParseModule); +``` + +--- + +## ๐ŸŒ TimeZone & Locale Awareness + +Tempo uses your configuration to resolve ambiguous dates. + +### US-Style Dates (`MM/DD/YYYY`) +If you parse a numeric string like `04012026`, Tempo uses your `timeZone` to decide if it means **April 1st** (US) or **4th of January** (UK/AU). + +```typescript +const us = new Tempo('04012026', { timeZone: 'America/New_York' }); // Apr 1 +const au = new Tempo('04012026', { timeZone: 'Australia/Sydney' }); // Jan 4 +``` + +### Custom Aliases (Events & Periods) +You can teach the parser new words: + +```typescript +Tempo.init({ + event: { + 'launch': '2026-12-01', + 'party': () => 'next Friday 8pm' + } +}); + +const t = new Tempo('party'); +``` + +--- + +## ๐Ÿ›ก๏ธ Performance: The Master Guard +Tempo uses a "Scan-and-Consume" engine called the **Master Guard**. This allows it to check your input string against dozens of patterns (weekdays, months, custom events) in a single pass, ensuring that parsing remains $O(1)$ relative to the number of plugins you have active. diff --git a/packages/tempo/img/docs-banner.png b/packages/tempo/img/docs-banner.png deleted file mode 100644 index a4949db3..00000000 Binary files a/packages/tempo/img/docs-banner.png and /dev/null differ diff --git a/packages/tempo/index.md b/packages/tempo/index.md index e5231c94..8ebc2203 100644 --- a/packages/tempo/index.md +++ b/packages/tempo/index.md @@ -3,7 +3,7 @@ layout: home --- + + + \ No newline at end of file diff --git a/packages/tempo/src/core.index.ts b/packages/tempo/src/core.index.ts index d429bc43..8f0a00e5 100644 --- a/packages/tempo/src/core.index.ts +++ b/packages/tempo/src/core.index.ts @@ -1,2 +1,9 @@ +import { Tempo } from './tempo.class.js'; + export * from './tempo.class.js'; +import { getRuntime } from '#tempo/support'; export { enums, Token, Snippet, Match, Default, Guard } from '#tempo/support'; + +getRuntime().modules['Tempo'] = Tempo; + +export default Tempo; diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts index 658fcc3b..67325004 100644 --- a/packages/tempo/src/plugin/extend/extend.ticker.ts +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -6,9 +6,9 @@ import { markConfig } from '#library/symbol.library.js' import { DURATIONS } from '../../support/tempo.enum.js' import { defineExtension, attachStatics } from '../plugin.util.js' -import sym from '../../support/tempo.symbol.js'; +import { sym } from '../../support/tempo.symbol.js'; import type { Tempo } from '../../tempo.class.js' -import type { TempoType } from '../plugin.type.js' +import type { Extension, TempoType } from '../plugin.type.js' declare module '../../tempo.class.js' { namespace Tempo { @@ -353,7 +353,7 @@ class TickerInstance implements Ticker.Descriptor { /** * # TickerModule */ -export const TickerModule: Tempo.Extension = defineExtension({ +export const TickerModule: Extension = defineExtension({ name: 'TickerModule', install(this: Tempo, TempoClass: TempoType) { attachStatics(TempoClass, { diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/plugin/module/module.duration.ts index d9e4bedd..9c0245a2 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/plugin/module/module.duration.ts @@ -6,6 +6,7 @@ import { getRelativeTime } from '#library/international.library.js'; import { defineInterpreterModule, interpret } from '../plugin.util.js'; import enums from '../../support/tempo.enum.js'; +import type { Module } from '../plugin.type.js'; import type { Tempo } from '../../tempo.class.js'; declare module '../../tempo.class.js' { @@ -154,7 +155,7 @@ duration.toDuration = (input: string | Temporal.DurationLikeObject) => { /** * Functional Module to attach duration methods to Tempo. */ -export const DurationModule: Tempo.Module = defineInterpreterModule('DurationModule', duration, { +export const DurationModule: Module = defineInterpreterModule('DurationModule', duration, { duration(this: typeof Tempo, input: any) { return interpret(this, 'DurationModule', 'toDuration', false, input); } diff --git a/packages/tempo/src/plugin/module/module.format.ts b/packages/tempo/src/plugin/module/module.format.ts index ac8f1f8f..f71386b6 100644 --- a/packages/tempo/src/plugin/module/module.format.ts +++ b/packages/tempo/src/plugin/module/module.format.ts @@ -9,7 +9,7 @@ import type { Tempo } from '../../tempo.class.js'; declare module '../../tempo.class.js' { interface Tempo { - /** applies a format to the instance. */ format(fmt: any): any; + /** applies a format to the instance. */ format(fmt: any): any; } } diff --git a/packages/tempo/src/plugin/module/module.lexer.ts b/packages/tempo/src/plugin/module/module.lexer.ts index 18550848..ae21fa2a 100644 --- a/packages/tempo/src/plugin/module/module.lexer.ts +++ b/packages/tempo/src/plugin/module/module.lexer.ts @@ -21,11 +21,22 @@ namespace Lexer { function num(groups: Record) { return ownEntries(groups) .reduce((acc: Record, [key, val]: [string, any]) => { - const low = isString(val) ? val.toLowerCase() : ''; - if (Number.isFinite(Number(val))) - acc[key] = Number(val); - else if (low in enums.NUMBER) + const v = isString(val) ? val.trim() : val; + if (v === '') return acc; + if (Number.isFinite(Number(v))) { + acc[key] = Number(v); + return acc; + } + + const resolved = prefix(val); + const low = isString(resolved) ? resolved.toLowerCase() : ''; + + if (low in enums.NUMBER) acc[key] = enums.NUMBER[low as t.Number]; + else if (resolved in enums.MONTH) + acc[key] = enums.MONTH[resolved as t.MONTH]; + else if (resolved in enums.WEEKDAY) + acc[key] = enums.WEEKDAY[resolved as t.WEEKDAY]; return acc; }, {} as Record); @@ -35,9 +46,10 @@ function num(groups: Record) { export function prefix(str: any): T { let value = str; if (isString(value)) { - const low = value.toLowerCase(); + const low = value.trim().toLowerCase(); + if (low === '') return value; const match = Object.keys(enums.NUMBER).find(key => key.startsWith(low)); - if (match) return enums.NUMBER[match as t.Number] as any; + if (match) return match as any; // search in weekdays and months for (const dict of [enums.WEEKDAY, enums.MONTH]) { @@ -136,7 +148,7 @@ export function parseDate(groups: t.Groups, dateTime: Temporal.ZonedDateTime, lo let { year, month, day } = num({ year: yy ?? dateTime.year, - month: mm ?? dateTime.month, + month: prefix((isString(mm) && mm.trim() === '') ? dateTime.month : (mm ?? dateTime.month)), day: dd ?? dateTime.day, } as any); diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/plugin/module/module.mutate.ts index c29ea7b8..64cfdca6 100644 --- a/packages/tempo/src/plugin/module/module.mutate.ts +++ b/packages/tempo/src/plugin/module/module.mutate.ts @@ -1,6 +1,7 @@ import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/type.library.js'; import { singular } from '#library/string.library.js'; -import sym from '../../support/tempo.symbol.js'; + +import { sym } from '../../support/tempo.symbol.js'; import enums from '../../support/tempo.enum.js'; import { defineInterpreterModule } from '../plugin.util.js'; import { findTermPlugin } from '../term.util.js'; diff --git a/packages/tempo/src/plugin/module/module.parse.ts b/packages/tempo/src/plugin/module/module.parse.ts index c2906548..71833c26 100644 --- a/packages/tempo/src/plugin/module/module.parse.ts +++ b/packages/tempo/src/plugin/module/module.parse.ts @@ -4,22 +4,36 @@ import { asArray, asInteger, isNumeric } from '#library/coercion.library.js'; import { instant } from '#library/temporal.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import type { Tempo } from '../../tempo.class.js'; +import { sym, enums, isTempo, Match, getRuntime } from '../../support/support.index.js'; import { prefix, parseWeekday, parseDate, parseTime, parseZone } from './module.lexer.js'; -import sym, { isTempo, Match, getRuntime } from '#tempo/support'; import { resolveTermMutation, resolveTermValue } from './module.term.js'; import { compose } from './module.composer.js'; -import { defineInterpreterModule } from '../plugin.util.js'; + import { getRange, getTermRange } from '../term.util.js'; +import { defineInterpreterModule } from '../plugin.util.js'; +import type { Range, ResolvedRange } from '../plugin.type.js'; +import type { Tempo } from '../../tempo.class.js'; import * as t from '../../tempo.type.js'; +/** + * Internal helper to resolve state from 'this' context or first argument + */ +const withState = (fn: Function) => function (this: any, ...args: any[]) { + const isBound = isTempo(this); + const state = isBound ? (this as any)[sym.$Internal]() : args.shift(); + + if (!isBound && (!isObject(state) || !state?.config || !state?.parse)) + throw new TypeError(`[Tempo#_ParseEngine] Invalid state provided to withState() wrapper. Expected Tempo state object (with .config and .parse), but received: ${typeof state}. This often happens if the first argument is missing when calling standalone parse methods.`); + + return fn.call(this, state, ...args); +}; + /** * Internal Parse Engine Implementation */ -const ParseEngine = { +const _ParseEngine = { /** parse DateTime input */ - parse(this: any, tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { - const state = this[sym.$Internal](); + parse(state: t.Internal.State, tempo: t.DateTime, dateTime?: Temporal.ZonedDateTime, term?: string): Temporal.ZonedDateTime { if (isNull(tempo)) { state.errored = true; return undefined as any; @@ -32,7 +46,7 @@ const ParseEngine = { try { const { config } = state; - const val = dateTime ?? state.anchor ?? (isTempo(tempo) ? tempo.toDateTime() : (isZonedDateTime(tempo) ? tempo : undefined)); + const val = dateTime ?? state.anchor ?? (isTempo(tempo) ? (tempo as any).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); @@ -40,20 +54,21 @@ const ParseEngine = { today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal)); - const TempoClass = this.constructor as typeof Tempo; + const TempoClass = getRuntime().modules['Tempo']; const terms = getRuntime().pluginsDb.terms; if (term) { const ident = term.startsWith('#') ? term.slice(1) : term; const termObj = terms.find((termEntry: any) => termEntry.key === ident || termEntry.scope === ident); if (!termObj) { - (TempoClass as any)[sym.$termError](state.config, term); + if (TempoClass) + (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 list = getRange(termObj, state as any, today); + const current = (getTermRange(state as any, 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); @@ -67,22 +82,22 @@ const ParseEngine = { const item = list[targetIdx]; if (item) { - const range = (getTermRange(this, [item], false, today) as any); + const range = (getTermRange(state as any, [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) ? [] : asArray(range as t.Range | t.Range[]); - const current = getTermRange(this, list, false, today) as t.ResolvedRange | undefined; + const range = termObj.define.call(state as any, false, today); + const list = isUndefined(range) ? [] : asArray(range as Range | Range[]); + const current = getTermRange(state as any, list, false, today) as ResolvedRange | undefined; if (current?.start) return current.start.toDateTime().withTimeZone(tz).withCalendar(cal); } } if (isString(tempo) && tempo.startsWith('#')) { - const res = resolveTermValue(TempoClass, this, tempo, today); + const res = resolveTermValue(TempoClass, state as any, tempo, today); if (isZonedDateTime(res)) return res; return undefined as any; } @@ -92,11 +107,11 @@ const ParseEngine = { if (termKey) { if (isUndefined(term)) { 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); + if (TempoClass) (TempoClass as any)[sym.$logError](state.config, msg); throw new Error(msg); } if (terms.length === 0) { - (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); return undefined as any; } } @@ -104,7 +119,7 @@ const ParseEngine = { const isAnchored = isDefined(dateTime) || isDefined(state.anchor); const resolvingKeys = new Set(); - const res = ParseEngine.conform.call(this, tempo, today, isAnchored, resolvingKeys); + const res = _ParseEngine.conform(state, tempo, today, isAnchored, resolvingKeys); const { timeZone: tz2, calendar: cal2 } = state.config; const targetTz = isString(tz2) ? tz2 : (tz2 as any).id ?? (tz2 as any).timeZoneId; @@ -139,27 +154,26 @@ const ParseEngine = { }, /** conform input to a Temporal.ZonedDateTime */ - conform(this: any, tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue { - const state = this[sym.$Internal](); + conform(state: any, tempo: t.DateTime, dateTime: Temporal.ZonedDateTime, isAnchored = false, resolvingKeys = new Set()): TypeValue { const arg = asType(tempo); - const { type, value } = arg; - const TempoClass = this.constructor as typeof Tempo; + let { type, value } = arg; + const TempoClass = getRuntime().modules['Tempo']; const terms = getRuntime().pluginsDb.terms; if (!isZonedDateTime(dateTime)) { - (TempoClass as any)[sym.$logError](state.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`)); + if (TempoClass) (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)) { + if (_ParseEngine.isZonedDateTimeLike(state, tempo)) { const { timeZone, calendar, value: _, ...options } = tempo as t.Options; const termKey = Object.keys(options).find(k => k.startsWith('#')); if (termKey && terms.length === 0) { - (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); return undefined as any; } @@ -170,7 +184,7 @@ const ParseEngine = { if (calendar) zdt = zdt.withCalendar(calendar); - ParseEngine.result.call(this, { type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' }); + _ParseEngine.result(state, { type: 'Temporal.ZonedDateTimeLike', value: zdt, match: 'Temporal.ZonedDateTimeLike' }); return Object.assign(arg, { type: 'Temporal.ZonedDateTime', @@ -185,14 +199,16 @@ const ParseEngine = { return Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: res }); } - if (type !== 'String' && type !== 'Number' && type !== 'Function' && type !== 'AsyncFunction') { - ParseEngine.result.call(this, arg, { match: type }); - return arg; + if (isZonedDateTime(value)) { + return Object.assign(arg, { type: 'Temporal.ZonedDateTime', value }); } if (isString(value)) { - const trim = (value as string).trim(); - const guard = (TempoClass as any)[sym.$guard].test(trim); + let trim = (value as string).trim(); + if (state.parse.ignorePattern) + trim = trim.replace(state.parse.ignorePattern, ' ').replace(Match.spaces, ' ').trim(); + + const guard = (TempoClass as any)?.[sym.$guard]?.test(trim) ?? true; if (!guard) { const keys = (obj: any) => { @@ -208,32 +224,32 @@ const ParseEngine = { const bypass = local.some(key => trim.toLowerCase().includes(String(key).toLowerCase())); if (!bypass) return arg; } + value = trim; // Update value for downstream parsing } - return ParseEngine.parseLayout.call(this, value as string | number, dateTime, isAnchored, resolvingKeys); + return _ParseEngine.parseLayout(state, 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()): TypeValue { - const state = this[sym.$Internal](); + parseLayout(state: any, 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 trim = value?.toString().trim() ?? ''; const resolving = new Set(resolvingKeys); - const TempoClass = this.constructor as typeof Tempo; + const TempoClass = getRuntime().modules['Tempo']; if (resolving.size >= 100) { - (TempoClass as any)[sym.$logError](state.config, new RangeError(`Infinite recursion detected in layout resolution for: ${String(value)}`)); + if (TempoClass) (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' }); + _ParseEngine.result(state, { type: 'Empty', value: trim, match: 'Empty' }); return Object.assign(arg, { type: 'Empty' }); } if (isIntegerLike(trim)) { - ParseEngine.result.call(this, arg, { match: 'BigInt' }); + _ParseEngine.result(state, { type: 'BigInt', value: asInteger(trim), match: 'BigInt' }); return Object.assign(arg, { type: 'BigInt', value: asInteger(trim) }); } } @@ -241,7 +257,7 @@ const ParseEngine = { 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)); + if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new TypeError(msg)); return arg; } } @@ -250,22 +266,21 @@ const ParseEngine = { 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); + for (const [symKey, pat] of state.parse.pattern) { + const groups = _ParseEngine.parseMatch(state, 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 } }); + _ParseEngine.result(state, { match: symKey.description, value: trim, groups: { ...groups } }); dateTime = parseZone(groups, dateTime, state.config); - dateTime = ParseEngine.parseGroups.call(this, groups, dateTime, isAnchored, resolvingKeys); + dateTime = _ParseEngine.parseGroups(state, 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 = 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)) @@ -279,8 +294,10 @@ const ParseEngine = { 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); + if (TempoClass) { + (TempoClass as any)[sym.$logDebug](state.config, 'groups', groups); + (TempoClass as any)[sym.$logDebug](state.config, 'pattern', symKey.description); + } break; } @@ -289,7 +306,7 @@ const ParseEngine = { }, /** apply a regex-match against a value, and clean the result */ - parseMatch(this: any, pat: RegExp, value: string | number) { + parseMatch(state: any, pat: RegExp, value: string | number) { const groups = value.toString().match(pat)?.groups || {} ownEntries(groups) @@ -300,10 +317,9 @@ const ParseEngine = { }, /** 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 { + parseGroups(state: 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 TempoClass = getRuntime().modules['Tempo']; const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -324,7 +340,7 @@ const ParseEngine = { if (key === 'slk') { const slk = groups[key]; - const result = resolveTermMutation(TempoClass, this, 'set', slk, undefined, dateTime); + const result = resolveTermMutation(TempoClass, state as any, 'set', slk, undefined, dateTime); if (result === null) { state.errored = true; resolved.add(key); @@ -341,7 +357,12 @@ const ParseEngine = { 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 src = isGlobal ? (isEvent ? (getRuntime().modules['Tempo'] as any)[sym.$Internal]().parse.event : (getRuntime().modules['Tempo'] as any)[sym.$Internal]().parse.period) : (isEvent ? state.parse.event : state.parse.period); + const globalParse = isGlobal ? (TempoClass as any)?.[sym.$Internal]?.().parse : undefined; + const src = + isGlobal + ? (isEvent ? globalParse?.event : globalParse?.period) + : (isEvent ? state.parse.event : state.parse.period); const entry = ownEntries(src, true)[idx]; @@ -354,7 +375,7 @@ const ParseEngine = { 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)); + if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); resolved.add(key); continue; } @@ -368,9 +389,27 @@ const ParseEngine = { try { state.anchor = dateTime; state.zdt = dateTime; - const result = (definition as Function).call(this); - if (isTempo(result)) dateTime = result.toDateTime(); + + // Provide a lightweight host context that mimics a Tempo instance for the handler + const host = { + add: (val: any) => dateTime.add(val), + set: (val: any) => isObject(val) ? dateTime.with(val) : dateTime, + toNow: () => Temporal.Now.zonedDateTimeISO(state.config.timeZone), + toDateTime: () => dateTime, + get hh() { return dateTime.hour }, + get mi() { return dateTime.minute }, + get ss() { return dateTime.second }, + get yy() { return dateTime.year }, + get mm() { return dateTime.month }, + get dd() { return dateTime.day }, + [sym.$isTempo]: true, + config: state.config + }; + + const result = (definition as Function).call(host); + if (isTempo(result)) dateTime = (result as any).toDateTime(); else if (isZonedDateTime(result)) dateTime = result as Temporal.ZonedDateTime; + else if (isObject(result) && isFunction((result as any).toDateTime)) dateTime = (result as any).toDateTime(); else dateTime = isZonedDateTime(state.zdt) ? (state.zdt as any) : dateTime; res = String(result); } catch (e: any) { @@ -386,18 +425,18 @@ const ParseEngine = { 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)}`); + if (TempoClass) (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 } }); + _ParseEngine.result(state, { 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); + const resMatch = _ParseEngine.parseLayout(state, res, dateTime, isAnchored, resolving); if (resMatch.type === 'Temporal.ZonedDateTime') dateTime = resMatch.value; @@ -420,30 +459,29 @@ const ParseEngine = { 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"); + if (TempoClass) groups["mm"] = (TempoClass as any).MONTH[mm as t.MONTH]!.toString().padStart(2, '0'); + else if (enums.MONTH[mm as t.MONTH]) groups["mm"] = enums.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 } { + isZonedDateTimeLike(state: 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')) + if (keys.some(key => state.OPTION.has(key) && key !== 'value')) return false; return keys .filter(isString) - .every((key: string) => (TempoClass as any)[sym.$Internal]().ZONED_DATE_TIME.has(key)) + .every((key: string) => state.ZONED_DATE_TIME.has(key)) }, /** accumulate match results */ - result(this: any, ...rest: Partial[]) { - const state = this[sym.$Internal](); + result(state: any, ...rest: Partial[]) { const match = Object.assign({}, ...rest) as t.Internal.Match; if (isDefined(state.anchor) && !match.isAnchored) @@ -456,6 +494,21 @@ const ParseEngine = { } }; +/** + * Public Parse Engine (wrapped for dual-mode support) + */ +export const ParseEngine = { + parse: withState(_ParseEngine.parse), + conform: withState(_ParseEngine.conform), + parseLayout: withState(_ParseEngine.parseLayout), + parseMatch: withState(_ParseEngine.parseMatch), + parseGroups: withState(_ParseEngine.parseGroups), + isZonedDateTimeLike: withState(_ParseEngine.isZonedDateTimeLike), + result: withState(_ParseEngine.result) +}; + +const isFunction = (v: any): v is Function => typeof v === 'function'; + /** * # ParseModule * The internal parsing engine for Tempo. diff --git a/packages/tempo/src/plugin/module/module.term.ts b/packages/tempo/src/plugin/module/module.term.ts index 9d354423..c32e71e9 100644 --- a/packages/tempo/src/plugin/module/module.term.ts +++ b/packages/tempo/src/plugin/module/module.term.ts @@ -1,13 +1,20 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; import { isDefined, isString, isZonedDateTime } from '#library/type.library.js'; -import { isNumeric } from '#library/coercion.library.js'; +import { asArray, isNumeric } from '#library/coercion.library.js'; -import sym from '../../support/tempo.symbol.js'; -import { getSafeFallbackStep } from '../../support/tempo.util.js'; -import { Match } from '../../support/tempo.default.js'; +import { sym, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../term.util.js'; +import { getHost } from '../plugin.util.js'; import { parseModifier } from './module.lexer.js'; +import type { Tempo } from '../../tempo.class.js'; +import type { TempoType } from '../plugin.type.js'; + +/** + * Internal helper to safely get the ZonedDateTime from a Tempo instance or raw object + */ +const toZdt = (v: any): Temporal.ZonedDateTime => isTempo(v) ? v.toDateTime() : v; + /** * Resolves a mutation (start/mid/end/add) against a Tempo Term. * @@ -19,7 +26,7 @@ import { parseModifier } from './module.lexer.js'; * @param zdt - The current ZonedDateTime state * @returns The mutated ZonedDateTime */ -export function resolveTermMutation(Tempo: any, instance: any, mutate: string, unit: string, offset: any, zdt: any): any { +export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: string, unit: string, offset: any, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { if (!isZonedDateTime(zdt)) return zdt; const [termPart, rangePart] = unit.startsWith('#') @@ -29,7 +36,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u const termObj = findTermPlugin(termPart); if (!termObj) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } @@ -74,12 +81,12 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u const rawList = getRange(termObj, instance, zdt); const currentRange = getTermRange(instance, rawList, false, zdt) as any; if (!currentRange) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } // Calculate cursor's offset within current range (nanoseconds) - const startNs = currentRange.start.toDateTime().epochNanoseconds as bigint; + const startNs = toZdt(currentRange.start).epochNanoseconds as bigint; const cursorNs = zdt.epochNanoseconds as bigint; const positionNs = cursorNs - startNs; @@ -97,8 +104,8 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u const matchKey = !rKey || range.key?.toLowerCase() === rKey.toLowerCase(); const hasMoved = (shiftDir > 0) - ? (range.start.toDateTime().epochNanoseconds as bigint) > (zdt.epochNanoseconds as bigint) - : (range.end.toDateTime().epochNanoseconds as bigint) < (zdt.epochNanoseconds as bigint); + ? (toZdt(range.start).epochNanoseconds as bigint) > (zdt.epochNanoseconds as bigint) + : (toZdt(range.end).epochNanoseconds as bigint) < (zdt.epochNanoseconds as bigint); if (matchKey && (iters > 1 || hasMoved)) { target = range; @@ -106,18 +113,18 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u } jump = (shiftDir > 0) - ? range.end.toDateTime() - : range.start.toDateTime().subtract({ nanoseconds: 1 }); + ? toZdt(range.end) + : toZdt(range.start).subtract({ nanoseconds: 1 }); } if (!target || remaining > 0) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } // Apply same position-offset, clamped to target range bounds - const tStartNs = target.start.toDateTime().epochNanoseconds as bigint; - const tEndNs = target.end.toDateTime().epochNanoseconds as bigint; + const tStartNs = toZdt(target.start).epochNanoseconds as bigint; + const tEndNs = toZdt(target.end).epochNanoseconds as bigint; let tNs = tStartNs + positionNs; if (tNs >= tEndNs) tNs = tEndNs - 1n; // clamp to range end if (tNs < tStartNs) tNs = tStartNs; // clamp to range start @@ -140,7 +147,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } candidates = rawList.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -184,7 +191,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (next) return next.start.withTimeZone(tz).withCalendar(cal); - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } @@ -202,7 +209,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } list = list.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -234,43 +241,43 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u const candidates = resolved.filter(c => rKey ? c.key?.toLowerCase() === rKey.toLowerCase() : true); // prefer latest start <= cursor (zdt) const prev = candidates - .filter(it => (it.start.toDateTime().epochNanoseconds as bigint) <= (zdt.epochNanoseconds as bigint)) + .filter(it => (toZdt(it.start).epochNanoseconds) <= (zdt.epochNanoseconds )) .sort((a, b) => { - const sa = a.start.toDateTime().epochNanoseconds as bigint; - const sb = b.start.toDateTime().epochNanoseconds as bigint; + const sa = toZdt(a.start).epochNanoseconds; + const sb = toZdt(b.start).epochNanoseconds; return sa === sb ? 0 : (sa > sb ? -1 : 1); })[0]; if (prev) { const target = prev; - const found = target.start.toDateTime().withTimeZone(tz).withCalendar(cal); + const found = toZdt(target.start).withTimeZone(tz).withCalendar(cal); remaining--; if (remaining === 0) { if (mutate === 'mid' || mutate === 'end') { jump = found; break; } return found; } - jump = target.end.toDateTime(); + jump = toZdt(target.end); continue; } // otherwise pick the nearest future start const next = candidates - .filter(it => (it.start.toDateTime().epochNanoseconds as bigint) > (zdt.epochNanoseconds as bigint)) + .filter(it => (toZdt(it.start).epochNanoseconds as bigint) > (zdt.epochNanoseconds)) .sort((a, b) => { - const sa = a.start.toDateTime().epochNanoseconds as bigint; - const sb = b.start.toDateTime().epochNanoseconds as bigint; + const sa = toZdt(a.start).epochNanoseconds; + const sb = toZdt(b.start).epochNanoseconds; return sa === sb ? 0 : (sa < sb ? -1 : 1); })[0]; if (next) { const target = next; - const found = target.start.toDateTime().withTimeZone(tz).withCalendar(cal); + const found = toZdt(target.start).withTimeZone(tz).withCalendar(cal); remaining--; if (remaining === 0) { if (mutate === 'mid' || mutate === 'end') { jump = found; break; } return found; } - jump = target.end.toDateTime(); + jump = toZdt(target.end); continue; } } @@ -280,22 +287,22 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (numericOnly && iterations > 1) { const candidates = resolved .filter(c => rKey ? c.key?.toLowerCase() === rKey.toLowerCase() : true) - .filter(it => (it.start.toDateTime().epochNanoseconds as bigint) > (jump.epochNanoseconds as bigint)) + .filter(it => (toZdt(it.start).epochNanoseconds as bigint) > (jump.epochNanoseconds as bigint)) .sort((a, b) => { - const sa = a.start.toDateTime().epochNanoseconds as bigint; - const sb = b.start.toDateTime().epochNanoseconds as bigint; + const sa = toZdt(a.start).epochNanoseconds as bigint; + const sb = toZdt(b.start).epochNanoseconds as bigint; return sa === sb ? 0 : (sa < sb ? -1 : 1); }); if (candidates.length > 0) { const target = candidates[0]; - const found = target.start.toDateTime().withTimeZone(tz).withCalendar(cal); + const found = toZdt(target.start).withTimeZone(tz).withCalendar(cal); remaining--; if (remaining === 0) { if (mutate === 'mid' || mutate === 'end') { jump = found; break; } return found; } - jump = target.end.toDateTime(); + jump = toZdt(target.end); continue; } } @@ -306,8 +313,8 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u const isShifter = (mod === '>' || mod === '<' || mod === 'next' || mod === 'prev' || mod === 'last' || mod === '+' || mod === '-'); const compare = (r: any) => { - const start = r.start.toDateTime().epochNanoseconds as bigint; - const end = r.end.toDateTime().epochNanoseconds as bigint; + const start = toZdt(r.start).epochNanoseconds as bigint; + const end = toZdt(r.end).epochNanoseconds as bigint; const cursor = (isShifter && iterations > 1) ? (jump.epochNanoseconds as bigint) : (zdt.epochNanoseconds as bigint); let match = false; @@ -340,9 +347,9 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u .filter(c => rKey ? c.key?.toLowerCase() === rKey.toLowerCase() : true) .filter(compare) .sort((a, b) => { - const startA = a.start.toDateTime().epochNanoseconds as bigint; - const startB = b.start.toDateTime().epochNanoseconds as bigint; - const cursor = jump.epochNanoseconds as bigint; + const startA = toZdt(a.start).epochNanoseconds; + const startB = toZdt(b.start).epochNanoseconds; + const cursor = jump.epochNanoseconds; if (isShifter) return direction > 0 ? (startA < startB ? -1 : 1) : (startA > startB ? -1 : 1); @@ -351,16 +358,16 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u return diffA < diffB ? -1 : (diffA > diffB ? 1 : 0); }).filter(m => { if (!isShifter) return true; - const start = m.start.toDateTime().epochNanoseconds as bigint; - const end = m.end.toDateTime().epochNanoseconds as bigint; - const cursor = jump.epochNanoseconds as bigint; + const start = toZdt(m.start).epochNanoseconds; + const end = toZdt(m.end).epochNanoseconds; + const cursor = jump.epochNanoseconds; if (direction > 0) return start >= cursor; return end <= cursor; }); if (matches.length > 0) { const target = matches[0]; - const found = target.start.toDateTime().withTimeZone(tz).withCalendar(cal); + const found = toZdt(target.start).withTimeZone(tz).withCalendar(cal); remaining--; if (remaining === 0) { if (mutate === 'mid' || mutate === 'end') { @@ -369,16 +376,16 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u } return found; } - jump = (direction > 0) ? target.end.toDateTime() : target.start.toDateTime().subtract({ nanoseconds: 1 }); + jump = (direction > 0) ? toZdt(target.end) : toZdt(target.start).subtract({ nanoseconds: 1 }); } else { const currentRes = (getTermRange(instance, rawList, false, jump) as any); if (!currentRes) { jump = (direction > 0) ? jump.add({ days: 30 }) : jump.subtract({ days: 30 }); continue; } - jump = (direction > 0) ? currentRes.end.toDateTime() : currentRes.start.toDateTime().subtract({ nanoseconds: 1 }); + jump = (direction > 0) ? toZdt(currentRes.end) : toZdt(currentRes.start).subtract({ nanoseconds: 1 }); } } if (remaining > 0) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } @@ -386,16 +393,16 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (mutate === 'mid' || mutate === 'end') { const finalRange = (getTermRange(instance, getRange(termObj, instance, jump), false, jump) as any); if (!finalRange) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } if (mutate === 'mid') { - const startNs = finalRange.start.toDateTime().epochNanoseconds as bigint; - const endNs = finalRange.end.toDateTime().epochNanoseconds as bigint; + const startNs = toZdt(finalRange.start).epochNanoseconds as bigint; + const endNs = toZdt(finalRange.end).epochNanoseconds as bigint; const midNs = startNs + (endNs - startNs) / BigInt(2); return toInstant(midNs).toZonedDateTimeISO(tz).withCalendar(cal); } - return finalRange.end.toDateTime().subtract({ nanoseconds: 1 }).withTimeZone(tz).withCalendar(cal); + return toZdt(finalRange.end).subtract({ nanoseconds: 1 }).withTimeZone(tz).withCalendar(cal); } return jump; } @@ -405,24 +412,39 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (isString(offset) && !offset.startsWith('#') && !isNumericString) { let jump = zdt; - const range = termObj.define.call(new Tempo(jump, { ...instance.config, mode: 'strict' }), false); - const step = getSafeFallbackStep(range as any, termObj.scope ?? (unit === '#period' ? 'period' : undefined)); - let next = jump.add(step); + const getStep = (currentRange: any) => { + if (currentRange) { + const items = asArray(currentRange); + const largestUnit = getLargestUnit(items); + const unitIndex = SCHEMA.findIndex(([u]) => u === largestUnit); + if (unitIndex !== -1) { + const rolloverIndex = Math.max(0, unitIndex - 1); + const stepUnit = SCHEMA[rolloverIndex][0]; + return { [`${stepUnit}s`]: 1 } as any; + } + } + + // Fallback if range doesn't define units + const fallbackUnit = termObj.scope ?? 'year'; + const stepUnit = fallbackUnit === 'period' ? 'day' : fallbackUnit; + return { [`${stepUnit}s`]: 1 } as any; + }; + + const range = termObj.define.call(new (getHost(instance))(jump, { ...instance.config, mode: 'strict' }), false); + let next = jump.add(getStep(range)); let iterations = 0; while (next.epochNanoseconds <= zdt.epochNanoseconds) { if (++iterations > 50) { // Safety-Valve: prevent infinite look-ahead - const range = termObj.define.call(new Tempo(jump, { ...instance.config, mode: 'strict' }), false); - const step = getSafeFallbackStep(range as any, termObj.scope ?? (unit === '#period' ? 'period' : undefined)); - jump = jump.add(step); + Tempo?.[sym.$termError]?.(instance.config, unit); + return null; } else { - const range = termObj.define.call(new Tempo(jump, { ...instance.config, mode: 'strict' }), false); - const step = getSafeFallbackStep(range as any, termObj.scope ?? (unit === '#period' ? 'period' : undefined)); - jump = jump.add(step); + const currentRange = termObj.define.call(new (getHost(instance))(jump, { ...instance.config, mode: 'strict' }), false); + jump = jump.add(getStep(currentRange)); next = jump; } } - const res = new Tempo(offset, { ...instance.config, anchor: next, mode: 'strict' }).toDateTime(); + const res = new (getHost(instance))(offset, { ...instance.config, anchor: next, mode: 'strict' }).toDateTime(); return isZonedDateTime(res) ? res : next; } @@ -430,9 +452,9 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u if (mutate === 'set' && !mod && isNumeric(offset)) { const rawList = getRange(termObj, instance, zdt); const target = getTermRange(instance, rawList, Number(offset), zdt) as any; - if (target) return target.start.toDateTime().withTimeZone(tz).withCalendar(cal); - - Tempo[sym.$termError](instance.config, unit); + if (target) return toZdt(target.start).withTimeZone(tz).withCalendar(cal); + + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } @@ -446,7 +468,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u let iterations = 0; while (remaining > 0) { if (++iterations > 100) { // Safety-Valve: prevent infinite shift - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } @@ -458,23 +480,23 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u } if (list.length === 0) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } - const res = resolveTermShift(new instance.constructor(jump, instance.config), list, unit, direction); + const res = resolveTermShift(new (getHost(instance))(jump, instance.config), list, unit, direction); if (isDefined(res)) { - jump = res.toDateTime(); + jump = toZdt(res); remaining--; } else { // if we hit the edge of the current list, jump to the end of the current cycle and try again const current = (getTermRange(instance, list, false, jump) as any); if (!current) { - Tempo[sym.$termError](instance.config, unit); + Tempo?.[sym.$termError]?.(instance.config, unit); return null; } - const nextJump = (direction > 0) ? current.end.toDateTime() : current.start.toDateTime().subtract({ nanoseconds: 1 }); + const nextJump = (direction > 0) ? toZdt(current.end) : toZdt(current.start).subtract({ nanoseconds: 1 }); if (nextJump.epochNanoseconds === jump.epochNanoseconds) { // detect zero-progress stall jump = (direction > 0) ? jump.add({ days: 1 }) : jump.subtract({ days: 1 }); } else { @@ -492,7 +514,7 @@ export function resolveTermMutation(Tempo: any, instance: any, mutate: string, u /** * Resolves a term identifier (e.g. '#quarter') to its current value (start of cycle). */ -export function resolveTermValue(Tempo: any, instance: any, term: string, zdt: any): any { +export function resolveTermValue(Tempo: TempoType, instance: Tempo, term: string, zdt: Temporal.ZonedDateTime): Temporal.ZonedDateTime | null { return resolveTermMutation(Tempo, instance, 'start', term, term, zdt); } diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 04fdcf4b..8bfd4979 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -1,7 +1,10 @@ import type { Prettify, Property } from '#library/type.library.js'; import type { Tempo } from '../tempo.class.js'; +import { TermError } from '../support/tempo.symbol.js'; -export type TempoType = typeof Tempo; +export type TempoType = typeof Tempo & { + [TermError]?: (config: any, term: string) => void; +} /** * ## TermPlugin diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 4f4623ed..3f4d1e36 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,12 +1,15 @@ import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/type.library.js'; import { secureRef } from '#library/proxy.library.js'; -import sym, { getRuntime } from '#tempo/support'; +import { sym, getRuntime, isTempo } from '#tempo/support'; import type { Tempo } from '../tempo.class.js'; import type { Plugin } from './plugin.type.js'; export function getHost(t: any): any { - return isFunction(t) || isClass(t) ? t : (t as any).constructor; + const TempoClass = getRuntime().modules['Tempo']; + if (isFunction(t) || isClass(t)) return t; + if (isTempo(t)) return (t as any).constructor ?? TempoClass; + return TempoClass ?? (t as any)?.constructor; } /** @@ -26,7 +29,7 @@ export function ensureModule(t: any, module: string, silent: boolean = false): b if (!isDefined(hostLogic) && !isTermsLoaded) { const baseName = mod.endsWith('Module') ? mod.slice(0, -6) : mod; - const msg = `Tempo: ${mod} not loaded. (Did you forget to Tempo.extend(${mod}) or import '#tempo/${baseName.toLowerCase()}'?)`; + const msg = `${mod} not loaded. (Did you forget to Tempo.extend(${mod}) or import '#tempo/${baseName.toLowerCase()}'?)`; if (!silent && isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); if (silent) return false; @@ -66,7 +69,7 @@ export function interpret(t: any, module: string, methodOrFallback?: any, silent return (hostLogic as any)[method].apply(t, args); } - const msg = `Tempo: ${module} method '${String(methodOrFallback)}' not found`; + const msg = `${module} method '${String(methodOrFallback)}' not found`; if (isFunction(host?.[sym.$logError])) host[sym.$logError](t?.config, msg); throw new Error(msg); } @@ -92,7 +95,7 @@ export const defineModule = (module: T): T => { export function attachStatics(TempoClass: any, props: Record) { for (const [key, val] of Object.entries(props)) { if (Object.hasOwn(TempoClass, key)) { - const msg = `Tempo: Static name collision on "${key}". Property is already defined on the host class.`; + const msg = `Static name collision on "${key}". Property is already defined on the host class.`; if (isFunction(TempoClass[sym.$logError])) { // use catch:true to report the collision without a fatal throw (supports re-extension in shared environments) TempoClass[sym.$logError]({ ...TempoClass.config, catch: true }, msg); diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts index 0545ae68..a3c319fe 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term.util.ts @@ -2,7 +2,7 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/type.library.js'; import { secure } from '#library/utility.library.js'; import { sortKey, byKey } from '#library/array.library.js'; -import sym, { SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; +import { sym, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; import type { Tempo } from '../tempo.class.js'; import type { TermPlugin, Range, ResolvedRange } from './plugin.type.js'; import { getHost } from './plugin.util.js'; @@ -75,7 +75,7 @@ export function getTermRange(tempo: Tempo, list: Range[], keyOnly: boolean | num // @ts-ignore const resZdt = toZonedDateTime({ ...obj, timeZone: anchor.timeZoneId, calendar: anchor.calendarId }); // @ts-ignore - return new tempo.constructor(resZdt, (tempo as any).config); + return new (getHost(tempo))(resZdt, (tempo as any).config); } const matchIndex = chronological.findLastIndex(range => { @@ -258,7 +258,7 @@ type resolveOptions = { */ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Record, { anchor, groupBy = [], ...options }: resolveOptions = {}): Range[] { // ensure we have a valid Tempo instance to work with - const t = isTempo(source) ? source : (isDefined(source) ? new (getHost(source))(source) : source); + const t = (isTempo(source) ? source : (isDefined(source) ? new (getHost(source))(source) : source)) as Tempo; if (!isTempo(t)) return []; // 1. Resolve Template (supporting optional dynamic grouping) diff --git a/packages/tempo/src/plugin/term/term.index.ts b/packages/tempo/src/plugin/term/term.index.ts index 57dc0491..ff61427b 100644 --- a/packages/tempo/src/plugin/term/term.index.ts +++ b/packages/tempo/src/plugin/term/term.index.ts @@ -1,5 +1,5 @@ import { defineModule } from '../plugin.util.js' -import sym, { getRuntime, onRegistryReset } from '#tempo/support'; +import { getRuntime, onRegistryReset } from '#tempo/support'; import { QuarterTerm } from './term.quarter.js' import { SeasonTerm } from './term.season.js' import { ZodiacTerm } from './term.zodiac.js' diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 5d590533..86c54f48 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -1,5 +1,3 @@ -import sym from './tempo.symbol.js'; - export { default as enums, STATE, @@ -27,11 +25,11 @@ export { NumericPattern } from './tempo.enum.js'; -export { isTempo } from './tempo.symbol.js'; +export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; -export { Match, Token, Snippet, Layout, Event, Period, Guard, Default } from './tempo.default.js'; -export { SCHEMA, getLargestUnit, getSafeFallbackStep } from './tempo.util.js'; +export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; +export { SCHEMA, getLargestUnit } from './tempo.util.js'; +export { init, extendState } from './tempo.init.js'; export { default as lib } from '#library/symbol.library.js'; -export default sym; diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index f5d391b4..19090343 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -1,17 +1,18 @@ import { looseIndex } from '#library/object.library.js'; import { secure } from '#library/utility.library.js'; import { proxify } from '#library/proxy.library.js'; +import { getDateTimeFormat } from '#library/international.library.js'; + import { NUMBER, MODE } from './tempo.enum.js'; +import { Token } from './tempo.symbol.js'; import type { Options } from '../tempo.type.js'; -import { getDateTimeFormat } from '#library/international.library.js'; import type { Tempo } from '../tempo.class.js'; -// BE VERY CAREFUL NOT TO BREAK THE REGEXP PATTERNS BELOW -// TEMPO functionality heavily depends on these patterns - /** characters allowed inside timezone/calendar brackets */ const bracket_content = /[^\]]+/; +// BE VERY CAREFUL NOT TO BREAK THE REGEXP PATTERNS BELOW +// TEMPO functionality heavily depends on these patterns /** @internal Tempo Match patterns */ export const Match = proxify({ /** match all {} pairs, if they start with a word char */ braces: /{([#]?[\w]+(?:\.[\w]+)*)}/g, @@ -34,47 +35,10 @@ export const Match = proxify({ /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** extracted value-only version of a slick shifter */ slickValue: /^(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, /** escape special regex characters in a string */ escape: (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), - /** numeric-only string detection */ numeric: /^\s*[-+]?\d+(\.\d+)?\s*$/ + /** numeric-only string detection */ numeric: /^\s*[-+]?\d+(\.\d+)?\s*$/, + /** match suspicious nested quantifiers (backtracking) */ backtrack: /(\(.*\)\+|\(.*\)\*|\(.*\)\{.*\})/, }, true, false); -/** @internal Tempo Symbol registry */ -export const Token = looseIndex()({ - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Snippet Symbols - /** year */ yy: Symbol('yy'), - /** ISO yearOfWeek */ yw: Symbol('yw'), - /** month */ mm: Symbol('mm'), - /** day */ dd: Symbol('dd'), - /** hour */ hh: Symbol('hh'), - /** minute */ mi: Symbol('mi'), - /** second */ ss: Symbol('ss'), - /** fraction */ ff: Symbol('ff'), - /** meridiem */ mer: Symbol('mer'), - /** short weekday name */ www: Symbol('www'), - /** relative-suffix */ afx: Symbol('afx'), - /** time-suffix */ sfx: Symbol('sfx'), - /** time unit */ unt: Symbol('unt'), - /** separator */ sep: Symbol('sep'), - /** modifier */ mod: Symbol('mod'), - /** generic number */ nbr: Symbol('nbr'), - /** Tempo slick shorthand */ slk: Symbol('slk'), - /** Tempo event */ evt: Symbol('evt'), - /** Tempo period */ per: Symbol('per'), - /** time zone offset */ tzd: Symbol('tzd'), - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Layout Symbols - /** date */ dt: Symbol('date'), - /** time */ tm: Symbol('time'), - /** date and time */ dtm: Symbol('dateTime'), - /** day-month-year */ dmy: Symbol('dayMonthYear'), - /** month-day-year */ mdy: Symbol('monthDayYear'), - /** year-month-day */ ymd: Symbol('yearMonthDay'), - /** day of month offset */ off: Symbol('offset'), - /** weekDay */ wkd: Symbol('weekDay'), - /** relative offset (years, days, hours, etc) */ rel: Symbol('relativeOffset'), - /** timezone/calendar brackets */ brk: Symbol('brackets'), -}) -/** @internal Tempo Symbol registry type */ -export type Token = typeof Token - /** * user will need to know these in order to configure their own patterns * Tempo.Snippet is a simple regex pattern object e.g. { Symbol('yy'): /(([0-9]{2})?[0-9]{2})/ } @@ -183,6 +147,13 @@ export const Period = looseIndex()({ /** @internal Tempo Period type */ export type Period = typeof Period +/** + * an {ignore} is a list of noise words to be stripped during parsing. + */ +/** @internal Tempo Ignore registry */ +export const Ignore = ['at', 'the', 'o-clock', 'o\'clock', 'on', 'in', 'of', 'by', 'for', 'to'] as const; +/** @internal Tempo Ignore type */ +export type Ignore = string | string[] | (() => string | string[]) /** @internal Tempo Master Guard list */ export const Guard = [ @@ -191,7 +162,7 @@ export const Guard = [ 'years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds', 'milliseconds', 'microseconds', 'nanoseconds', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'mondays', 'tuesdays', 'wednesdays', 'thursdays', 'fridays', 'saturdays', 'sundays' -] +] as const; /** @internal Tempo Default options */ export const Default = secure({ diff --git a/packages/tempo/src/support/tempo.enum.ts b/packages/tempo/src/support/tempo.enum.ts index bcb20719..40ca4530 100644 --- a/packages/tempo/src/support/tempo.enum.ts +++ b/packages/tempo/src/support/tempo.enum.ts @@ -1,12 +1,9 @@ import lib from '#library/symbol.library.js'; import { enumify, Enum } from '#library/enumerate.library.js'; import { proxify } from '#library/proxy.library.js'; -import { ownKeys } from '#library/primitive.library.js'; import { allDescriptors } from '#library/reflection.library.js'; -import { clearCache } from '#library/function.library.js'; -import { isUndefined, isDefined } from '#library/type.library.js'; -import type { OwnOf, KeyOf, ValueOf, LooseUnion, Mutable, Property } from '#library/type.library.js'; -import sym from './tempo.symbol.js'; +import { looseIndex } from '#library/object.library.js'; +import type { OwnOf, KeyOf, ValueOf, LooseUnion, Mutable } from '#library/type.library.js'; /** calendar seasons */ export const SEASON = enumify({ @@ -136,12 +133,13 @@ export type MONTHS = KeyOf export type Months = ValueOf /** number names (0-10) */ -export const NUMBER = proxify(enumify(STATE.NUMBER, false), true, false); +export const NUMBER = looseIndex()(proxify(enumify(STATE.NUMBER, false), true, false)); +export type NUMBER = typeof NUMBER; export type Number = KeyOf /** common time-zone aliases */ -export const TIMEZONE = proxify(STATE.TIMEZONE, true, false); -export type TIMEZONE = KeyOf +export const TIMEZONE = looseIndex()(proxify(STATE.TIMEZONE, true, false)); +export type TIMEZONE = typeof TIMEZONE; export type Timezone = ValueOf /** number of seconds in a time unit */ @@ -153,8 +151,8 @@ export const DURATIONS = enumify(STATE.DURATIONS, false); export type DURATIONS = KeyOf /** common format aliases */ -export const FORMAT = proxify(enumify(STATE.FORMAT, false), true, false); -export type FORMAT = ValueOf +export const FORMAT = looseIndex()(proxify(enumify(STATE.FORMAT, false), true, false)); +export type FORMAT = typeof FORMAT; export type Format = LooseUnion & string> /** patterns that return a number */ diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts new file mode 100644 index 00000000..d88bcc4d --- /dev/null +++ b/packages/tempo/src/support/tempo.init.ts @@ -0,0 +1,148 @@ +import '#library/temporal.polyfill.js'; +import { enumify } from '#library/enumerate.library.js'; +import { asArray } from '#library/coercion.library.js'; +import { getDateTimeFormat, getHemisphere } from '#library/international.library.js'; +import { markConfig } from '#library/symbol.library.js'; +import { ownEntries } from '#library/primitive.library.js'; + +import { getRuntime } from './tempo.runtime.js'; +import { setProperty, hasOwn, create, collect, setPatterns } from './tempo.util.js'; +import { sym, Token } from './tempo.symbol.js'; +import { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; +import enums, { STATE } from './tempo.enum.js'; +import * as t from '../tempo.type.js'; +import type { Mode } from '../tempo.type.js'; + +/** @internal Initialise the global Tempo state */ +export function init(options: t.Options = {}): t.Internal.State { + const runtime = getRuntime(); + if (runtime.state) return runtime.state; + + const { timeZone, calendar } = getDateTimeFormat(); + + const state = { + config: {}, + parse: {} + } as t.Internal.State + + // 1. Establish the base parsing state + state.parse = markConfig({ + token: Token, + result: [], + snippet: Object.assign({}, Snippet), + layout: Object.assign({}, Layout), + event: Object.assign({}, Event), + period: Object.assign({}, Period), + ignore: Object.fromEntries(asArray(Ignore).map(w => [w, w])), + mdyLocales: asArray(Default.mdyLocales as any), + mdyLayouts: asArray(Default.mdyLayouts as any), + pivot: Default.pivot as any, + mode: Default.mode as any, + lazy: false, + pattern: new Map(), + }); + + // 2. Establish the base configuration options + state.config = markConfig(Object.create(Default)); + Object.defineProperties(state.config, { + calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, + timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, + locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, + discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, + formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, + sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, + get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, + scope: { value: 'global', enumerable: true, writable: true, configurable: true }, + catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } + }); + + // 3. Initialize registries that need objects + state.OPTION = new Set(Object.keys(Default)); + state.ZONED_DATE_TIME = new Set(['year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'offset', 'timeZone', 'calendar']); + + runtime.state = state; + return state; +} + +/** @internal Extend a Tempo state with new options (Shadowing) */ +export function extendState(state: t.Internal.State, options: t.Options) { + const { + isString = (v: any) => typeof v === 'string', + isObject = (v: any) => typeof v === 'object' && v !== null, + isUndefined = (v: any) => v === undefined, + isRegExp = (v: any) => v instanceof RegExp, + asType = (v: any) => ({ type: Object.prototype.toString.call(v).slice(8, -1), value: v }) + } = getRuntime().modules['Library'] ?? {}; + + let patternsDirty = false; + ownEntries(options).forEach(([optKey, optVal]) => { + if (isUndefined(optVal)) return; + const arg = asType(optVal); + + switch (optKey) { + case 'snippet': + case 'layout': + case 'event': + case 'period': + case 'ignore': { + patternsDirty = true; + if (!hasOwn(state.parse, optKey)) + state.parse[optKey] = create(state.parse, optKey); + + const rule = state.parse[optKey]; + if (['snippet', 'layout'].includes(optKey)) { + collect(rule, arg.value, (v: any) => { + if (optKey === 'snippet') { + const pattern = isRegExp(v) ? v.source : String(v); + // ๐Ÿ›ก๏ธ Security Check: Prevent catastrophic backtracking and malicious patterns + if (pattern.length > 500) throw new Error(`[Tempo#extend] Snippet pattern too long (max 500 chars).`); + if (Match.backtrack.test(pattern)) throw new Error(`[Tempo#extend] Snippet contains suspicious nested quantifiers.`); + return new RegExp(pattern); + } + return isRegExp(v) ? v.source : v; + }); + } else { + asArray(arg.value).forEach(elm => { + if (isObject(elm)) Object.assign(rule, elm); + else if (isString(elm)) Object.assign(rule, { [elm]: elm }); + }) + } + break; + } + + case 'timeZone': { + const zone = String(arg.value).toLowerCase(); + const resolvedZone = enums.TIMEZONE[zone] ?? arg.value; + setProperty(state.config, 'timeZone', resolvedZone); + setProperty(state.config, 'sphere', getHemisphere(resolvedZone)); + break; + } + + case 'calendar': + setProperty(state.config, 'calendar', String(arg.value)); + break; + + case 'locale': + setProperty(state.config, 'locale', String(arg.value)); + break; + + case 'pivot': { + const v = Number(arg.value); + if (Number.isInteger(v) && v >= 0) state.parse.pivot = v; + break; + } + + case 'mode': + state.parse.mode = String(arg.value) as Mode; + break; + + case 'anchor': + state.anchor = arg.value; + break; + } + }); + + if (patternsDirty) setPatterns(state); + + return state; +} diff --git a/packages/tempo/src/support/tempo.runtime.ts b/packages/tempo/src/support/tempo.runtime.ts index 0a58e980..c769fd91 100644 --- a/packages/tempo/src/support/tempo.runtime.ts +++ b/packages/tempo/src/support/tempo.runtime.ts @@ -1,5 +1,6 @@ -import sym from './tempo.symbol.js'; +import { sym } from './tempo.symbol.js'; import type { TermPlugin, Extension, Plugin } from '../plugin/plugin.type.js'; +import type { Internal } from '../tempo.type.js'; /** * # TempoRuntime @@ -12,23 +13,22 @@ import type { TermPlugin, Extension, Plugin } from '../plugin/plugin.type.js'; * slot a possible tamper target. * * `TempoRuntime` replaces all of those slots with a **single** well-known entry on - * `globalThis` (the BRIDGE symbol above). The slot is defined as non-enumerable, + * `globalThis` (the `$Bridge` symbol). The slot is defined as non-enumerable, * non-configurable and non-writable so external code cannot replace or delete the * runtime object. All mutation goes through the controlled methods on this class. * * ## Multi-bundle / HMR compatibility - * `getRuntime()` checks `globalThis[BRIDGE]` before creating a new instance. When + * `getRuntime()` checks `globalThis[$Bridge]` before creating a new instance. When * two bundle copies of the library are loaded (monorepo, HMR, etc.), both find the * same runtime and therefore share the same arrays / sets โ€” the same guarantee that * was previously achieved by scattering `Symbol.for(โ€ฆ)` writes across many slots. * * ## Scoped runtimes (Experimental) - * `TempoRuntime.createScoped()` returns a fresh, isolated runtime that is *not* stored on `globalThis`, enabling clean test isolation without globalThis manipulation. **Note**: Scoped runtimes are currently an experimental internal feature and are not yet fully threaded through all core utilities. Scoped runtimes are not pinned to `globalThis`, lack the `defineProperty` descriptor protections of the primary instance, and instead rely solely on the lexical reference returned (contrasting with the hardened `getRuntime()` and `globalThis[BRIDGE]` behavior). Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). + * `TempoRuntime.createScoped()` returns a fresh, isolated runtime that is *not* stored on `globalThis`, enabling clean test isolation without globalThis manipulation. **Note**: Scoped runtimes are currently an experimental internal feature and are not yet fully threaded through all core utilities. Scoped runtimes are not pinned to `globalThis`, lack the `defineProperty` descriptor protections of the primary instance, and instead rely solely on the lexical reference returned (contrasting with the hardened `getRuntime()` and `globalThis[$Bridge]` behavior). Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). */ export class TempoRuntime { - constructor() { - (this as any)[sym.$RuntimeBrand] = true; - } + constructor() { (this as any)[sym.$RuntimeBrand] = true; } + /** raw extension-plugin storage array โ€” consumed by REGISTRY */ readonly extensions: Extension[] = []; /** raw named-module map โ€” consumed by REGISTRY */ @@ -39,14 +39,17 @@ export class TempoRuntime { readonly resetHooks: Set<() => void> = new Set(); /** * Persistent plugin/term discovery database. - * Replaces the `globalThis[sym.$Plugins]` slot. * Kept as a plain object (not a secureRef) so callers can push() into the arrays. - */ + */ readonly pluginsDb: { terms: TermPlugin[]; plugins: Plugin[] } = { terms: [], plugins: [] }; - readonly #hooks: Map void> = new Map(); + /** persistent global configuration state โ€” mirrors Tempo.#global */ + state?: Internal.State | undefined; + /** cache for next-available 'usr' Token key */ + usrCount: number = 0; // โ”€โ”€โ”€ Register hook โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + readonly #hooks: Map void> = new Map(); /** Set a registration hook for a given symbol. Returns the previous hook. */ setHook(key: symbol, cb: (val: any) => void): ((val: any) => void) | undefined { @@ -66,7 +69,6 @@ export class TempoRuntime { } // โ”€โ”€โ”€ Validated mutation helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /** * Record a Term in the discovery database. * Validates the shape before storing so malformed entries cannot corrupt state. @@ -98,7 +100,6 @@ export class TempoRuntime { } // โ”€โ”€โ”€ Factory helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - /** * @internal @experimental * Create a fresh, **scoped** runtime that is NOT stored on `globalThis`. @@ -115,10 +116,10 @@ let localFallbackRuntime: TempoRuntime | undefined; /** * Return the singleton `TempoRuntime`. * - * On the first call the runtime is created and pinned to `globalThis` under the BRIDGE + * On the first call the runtime is created and pinned to `globalThis` under the $Bridge * symbol with a hardened property descriptor (non-enumerable, non-configurable, * non-writable). Subsequent calls โ€” even from other bundle copies โ€” retrieve the same - * object via `globalThis[BRIDGE]`, preserving the single-source-of-truth guarantee. + * object via `globalThis[$Bridge]`, preserving the single-source-of-truth guarantee. */ export function getRuntime(): TempoRuntime { const existing = (globalThis as any)[sym.$Bridge]; diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index 2b95298a..01d7a7bb 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -1,4 +1,7 @@ -import type { Tempo } from '../tempo.class.js'; +import { looseIndex } from '#library/object.library.js'; + +/** check valid Tempo instance */ +export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$isTempo]); /** * Centralized registry for all Tempo-specific Global Symbols. @@ -7,25 +10,67 @@ import type { Tempo } from '../tempo.class.js'; * clean separation of concerns. */ +export const IsTempo: unique symbol = Symbol.for('magmacomputing/tempo/isTempo') as any; +export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termError') as any; + /** @internal Tempo Symbol Registry */ -const sym = { - /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), - /** key for Reactive Plugin Registration */ $Register: Symbol.for('magmacomputing/tempo/register'), - /** key for Global Identity Brand for Tempo */ $isTempo: Symbol.for('magmacomputing/tempo/isTempo'), - /** key for Internal Interpreter Service */ $Interpreter: Symbol.for('magmacomputing/tempo/interpreter'), - /** key for contextual Error Logging */ $logError: Symbol.for('magmacomputing/tempo/logError'), - /** key for contextual Debug Logging */ $logDebug: Symbol.for('magmacomputing/tempo/logDebug'), - /** key for centralized Term Error dispatching */ $termError: Symbol.for('magmacomputing/tempo/termError'), - /** key for contextual Debugger */ $dbg: Symbol.for('magmacomputing/tempo/dbg'), - /** key for Master Guard */ $guard: Symbol.for('magmacomputing/tempo/guard'), - /** internal key for signaling pre-errored state */ $errored: Symbol.for('magmacomputing/tempo/errored'), - /** internal key for accessing private instance state */$Internal: Symbol.for('magmacomputing/tempo/internal'), - /** hardened globalThis bridge key for the TempoRuntime */$Bridge: Symbol.for('magmacomputing/tempo/runtime'), - /** cross-bundle brand check for TempoRuntime */ $RuntimeBrand: Symbol.for('magmacomputing/tempo/runtime/brand'), - /** branding for explicit PropertyDescriptors */ $Descriptor: Symbol.for('magmacomputing/tempo/descriptor'), +export const sym = { + /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), + /** key for Reactive Plugin Registration */ $Register: Symbol.for('magmacomputing/tempo/register'), + /** key for Global Identity Brand for Tempo */ $isTempo: IsTempo, + /** key for centralized Term Error dispatching */ $termError:TermError, + /** key for Internal Interpreter Service */ $Interpreter: Symbol.for('magmacomputing/tempo/interpreter'), + /** key for contextual Error Logging */ $logError: Symbol.for('magmacomputing/tempo/logError'), + /** key for contextual Debug Logging */ $logDebug: Symbol.for('magmacomputing/tempo/logDebug'), + /** key for contextual Debugger */ $dbg: Symbol.for('magmacomputing/tempo/dbg'), + /** key for Master Guard */ $guard: Symbol.for('magmacomputing/tempo/guard'), + /** internal key for signaling pre-errored state */ $errored: Symbol.for('magmacomputing/tempo/errored'), + /** internal key for accessing private instance state */ $Internal: Symbol.for('magmacomputing/tempo/internal'), + /** hardened globalThis bridge key for the TempoRuntime */$Bridge: Symbol.for('magmacomputing/tempo/runtime'), + /** cross-bundle brand check for TempoRuntime */ $RuntimeBrand: Symbol.for('magmacomputing/tempo/runtime/brand'), + /** branding for explicit PropertyDescriptors */ $Descriptor: Symbol.for('magmacomputing/tempo/descriptor'), } as const; -/** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is Tempo => tempo?.[sym.$isTempo] === true; +/** @internal Local interface for brand checking without circular imports */ +export interface TempoBrand { + [sym.$isTempo]: true; + toDateTime(): Temporal.ZonedDateTime; + config: any; +} -export default sym; +/** @internal Tempo Token registry */ +export const Token = looseIndex()({ + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Snippet Symbols + /** year */ yy: Symbol('yy'), + /** ISO yearOfWeek */ yw: Symbol('yw'), + /** month */ mm: Symbol('mm'), + /** day */ dd: Symbol('dd'), + /** hour */ hh: Symbol('hh'), + /** minute */ mi: Symbol('mi'), + /** second */ ss: Symbol('ss'), + /** fraction */ ff: Symbol('ff'), + /** meridiem */ mer: Symbol('mer'), + /** short weekday name */ www: Symbol('www'), + /** relative-suffix */ afx: Symbol('afx'), + /** time-suffix */ sfx: Symbol('sfx'), + /** time unit */ unt: Symbol('unt'), + /** separator */ sep: Symbol('sep'), + /** modifier */ mod: Symbol('mod'), + /** generic number */ nbr: Symbol('nbr'), + /** Tempo slick shorthand */ slk: Symbol('slk'), + /** Tempo event */ evt: Symbol('evt'), + /** Tempo period */ per: Symbol('per'), + /** time zone offset */ tzd: Symbol('tzd'), + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Layout Symbols + /** date */ dt: Symbol('date'), + /** time */ tm: Symbol('time'), + /** date and time */ dtm: Symbol('dateTime'), + /** day-month-year */ dmy: Symbol('dayMonthYear'), + /** month-day-year */ mdy: Symbol('monthDayYear'), + /** year-month-day */ ymd: Symbol('yearMonthDay'), + /** day of month offset */ off: Symbol('offset'), + /** weekDay */ wkd: Symbol('weekDay'), + /** relative offset (years, days, hours, etc) */ rel: Symbol('relativeOffset'), + /** timezone/calendar brackets */ brk: Symbol('brackets'), +}); +export type Token = typeof Token; diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 7f8b3f8c..2d80fc94 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -1,40 +1,164 @@ -import { isDefined } from '#library/type.library.js'; -import { asArray } from '#library/coercion.library.js'; -import type { Tempo } from '../tempo.class.js'; -import type { Range, DateTimeUnit } from '../tempo.type.js'; +import { sym, Token } from './tempo.symbol.js'; +import { isSymbol, isUndefined, isString, isRegExp, isNullish, isRegExpLike, asType } from '#library/type.library.js'; +import { ownEntries, ownKeys } from '#library/primitive.library.js'; +import { getRuntime } from './tempo.runtime.js'; +import { Match, Snippet, Layout } from './tempo.default.js'; +import enums from './tempo.enum.js'; +import type * as t from '../tempo.type.js'; -/** internal schema for Temporal units and their Tempo property aliases */ +/** @internal set a mutable, enumerable property on a target */ +export const setProperty = (target: object, key: PropertyKey, value: T) => + Object.defineProperty(target, key, { value, writable: true, configurable: true, enumerable: true }); + +/** @internal return the Prototype parent of an object */ +export const proto = (obj: object) => Object.getPrototypeOf(obj); + +/** @internal test object has own property with the given key */ +export const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key); + +/** @internal create an object based on a prototype */ +export const create = (obj: object, name: string): T => { + const entry = proto(obj)[name]; + if (typeof entry !== 'object' || entry === null) { + throw new TypeError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); + } + return Object.create(entry); +}; + +/** @internal resolve a key to a symbol from Token or sym registries */ +export function getSymbol(key?: string | symbol): symbol { + if (isSymbol(key)) + return key as symbol; + + if (isUndefined(key)) { + const runtime = getRuntime(); + const usr = `usr.${++runtime.usrCount}`; // allocate a prefixed 'user' key + return (Token as any)[usr] = Symbol(usr); // add to Symbol register + } + + if (isString(key) && (key as string).includes('.')) { + const description = (key as string).split('.').pop()!; // use last segment as description + return (Token as any)[key as string] ??= Symbol(description); + } + + return (Token as any)[key!] ?? (sym as any)[key!] ?? Symbol.for(`$Tempo.${key as string}`); +} + +/** @internal helper to normalize snippet/layout Options into the target Config */ +export function collect(target: Record, value: any, convert: (v: any) => any) { + const itm = asType(value); + + switch (itm.type) { + case 'Object': + ownEntries(itm.value as Record) + .forEach(([k, v]) => target[getSymbol(k)] = convert(v)); + break; + case 'String': + case 'RegExp': + target[getSymbol()] = convert(itm.value); + break; + case 'Array': + (itm.value as any[]).forEach(elm => collect(target, elm, convert)); + break; + } +} + +/** @internal standard date/time component order */ export const SCHEMA = [ - ['year', 'yy'], - ['month', 'mm'], - ['day', 'dd'], - ['hour', 'hh'], - ['minute', 'mi'], - ['second', 'ss'], - ['millisecond', 'ms'], - ['microsecond', 'us'], - ['nanosecond', 'ns'] -] as [DateTimeUnit, keyof Tempo][]; - -/** helper to find the largest Temporal unit defined in a Range list */ -export function getLargestUnit(list: Range | Range[]): DateTimeUnit | undefined { - const items = asArray(list); - return SCHEMA.find(([u]) => items.some(r => isDefined(r[u])))?.[0]; + ['year', 'yy'], ['month', 'mm'], ['day', 'dd'], ['hour', 'hh'], ['minute', 'mi'], ['second', 'ss'], ['millisecond', 'ms'], ['microsecond', 'us'], ['nanosecond', 'ns'] +] as const; + +/** @internal get the largest defined unit from a list of ranges */ +export function getLargestUnit(list: any[]): string { + for (const [unit] of SCHEMA) { + if (list.some(r => r[unit] !== undefined)) return unit; + } + return 'nanosecond'; } -/** helper to determine a safe forward step for infinite-loop recovery */ -export function getSafeFallbackStep(range: Range | Range[], scope?: string): Temporal.DurationLike { - const items = asArray(range); - const first = items[0] as any; - - // prioritize stashed 'rollover' metadata (calculated by getTermRange) if available - const rolloverUnit = first?.rollover || (() => { - const unit = getLargestUnit(items); - const unitIndex = SCHEMA.findIndex(([u]) => u === unit); - const rolloverIndex = Math.max(0, unitIndex - 1); - return (unitIndex !== -1) ? SCHEMA[rolloverIndex][0] : undefined; - })(); - - if (rolloverUnit) return { [`${rolloverUnit}s`]: 1 } as any; - return scope === 'period' ? { days: 1 } : { years: 1 }; +/** @internal translates {layout} into an anchored, case-insensitive RegExp. */ +export function compileRegExp(layout: string | RegExp, state: t.Internal.State, snippet?: Snippet) { + // helper function to replace {name} placeholders with their corresponding snippets + const matcher = (source: string, d = 0): string => { + if (d > 10) return source; // prevent infinite recursion + + if (source.startsWith('/') && source.endsWith('/')) + source = source.substring(1, source.length - 1); // remove the leading/trailing "/" + if (source.startsWith('^') && source.endsWith('$')) + source = source.substring(1, source.length - 1); // remove the leading/trailing anchors (^ $) + + return source.replaceAll(new RegExp(Match.braces), (match, name) => { // iterate over "{}" pairs in the source string + const token = getSymbol(name); // get the symbol for this {name} + const customs = snippet?.[token as keyof Snippet]?.source ?? snippet?.[name as keyof Snippet]?.source; + const globals = state.parse.snippet[token as keyof Snippet]?.source ?? state.parse.snippet[name as keyof Snippet]?.source; + const defaultLayout = Layout[token as keyof typeof Layout]; // get resolution source (layout) + + let res = customs ?? globals ?? defaultLayout; // get the snippet/layout source + + if (isNullish(res) && name.includes('.')) { // if no definition found, try fallback + const prefix = name.split('.')[0]; // get the base token name + const pToken = getSymbol(prefix); + res = snippet?.[pToken as keyof Snippet]?.source ?? snippet?.[prefix as keyof Snippet]?.source + ?? state.parse.snippet[pToken as keyof typeof Snippet]?.source ?? state.parse.snippet[prefix as keyof typeof Snippet]?.source + ?? Layout[pToken as keyof typeof Layout]; + } + + if (res && name.includes('.')) { // wrap dotted extensions for identification + const safeName = name.replace(/\./g, '_'); + if (!res.startsWith(`(?<${safeName}>`)) + res = `(?<${safeName}>${res})`; + } + + return (isNullish(res) || res === match) // if no definition found, + ? match // return the original match + : matcher(res, d + 1); // else recurse to see if snippet contains embedded "{}" pairs + }); + }; + + try { + const source = isRegExp(layout) ? layout.source : layout; + const expanded = matcher(source); + return new RegExp(`^(${expanded})$`, 'i'); + } catch (e: any) { + return new RegExp(`^${Match.escape(layout as string)}$`, 'i'); + } +} + +const isEmpty = (v: any) => !v || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && Object.keys(v).length === 0); + +/** @internal build RegExp patterns into the state */ +export function setPatterns(state: t.Internal.State) { + const snippet = state.parse.snippet; + + // 1. ensure numeric snippets are current + if (enums?.NUMBER) { + const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); // escape each key + const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); + snippet[Token.nbr] = nbr; + snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); + snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep].source}?`); + } + + // 2. build ignore pattern + const ignores = ownKeys(state.parse.ignore, true); + if (!isEmpty(ignores)) { + const words = ignores + .filter(isString) + .map(w => Match.escape(w.toLowerCase())) + .join('|'); + state.parse.ignorePattern = new RegExp(`\\b(${words})\\b`, 'gi'); + } else { + delete state.parse.ignorePattern; + } + + // ensure we have our own Map to mutate + state.parse.pattern = new Map(state.parse.pattern); + + // 3. build the patterns + ownEntries(state.parse.layout).forEach(([key, layout]) => { + const symbol = getSymbol(key); + const compiled = compileRegExp(layout, state, snippet); + state.parse.pattern.set(symbol, compiled); + // console.log(`DEBUG Compiled [${String(symbol)}]:`, compiled.source.substring(0, 50) + '...'); + }); } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 088d35ca..584fec69 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -12,16 +12,16 @@ 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, isNullish, isDefined, isUndefined, isString, isObject, isRegExp, isRegExpLike, isSymbol, isFunction, isClass, isZonedDateTime } from '#library/type.library.js'; +import { getType, asType, isEmpty, isDefined, isUndefined, isString, isObject, isRegExp, isSymbol, isFunction, isClass, isZonedDateTime, Property, Secure } from '#library/type.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; -import { instant } from '#library/temporal.library.js'; -import type { Property, Secure } from '#library/type.library.js'; - import { registerPlugin, interpret, ensureModule } from './plugin/plugin.util.js' import { registerTerm, getTermRange } from './plugin/term.util.js'; +import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; +import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns } from './support/tempo.util.js'; -import sym, { getRuntime, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Default, Guard, enums, STATE, DISCOVERY } from '#tempo/support'; +import { sym, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, type TempoBrand } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) +import { instant } from '#library/temporal.library.js'; declare module '#library/type.library.js' { interface TypeValueMap { @@ -31,20 +31,9 @@ declare module '#library/type.library.js' { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ const Context = getContext(); // current execution context -/** set a mutable, enumerable property on a target */ -const setProperty = (target: object, key: PropertyKey, value: T) => - Object.defineProperty(target, key, { - value, writable: true, configurable: true, enumerable: true - }); - -/** return the Prototype parent of an object */ -const proto = (obj: object) => Object.getPrototypeOf(obj); -/** test object has own property with the given key */ -const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key); /** return whether the shape is 'local' or 'global' */ 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]); + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ namespace Internal { export type State = t.Internal.State; @@ -69,7 +58,7 @@ namespace Internal { */ @Serializable @Immutable -export class Tempo { +export class Tempo implements TempoBrand { /** Weekday names (short-form) */ static get WEEKDAY() { return enums.WEEKDAY } /** Weekday names (long-form) */ static get WEEKDAYS() { return enums.WEEKDAYS } /** Month names (short-form) */ static get MONTH() { return enums.MONTH } @@ -90,15 +79,12 @@ export class Tempo { /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !Tempo.#lifecycle.ready } /** @internal check if Tempo is currently extending */ static get isExtending() { return Tempo.#lifecycle.extendDepth > 0 } - static #dbg = new Logify('Tempo', { - debug: Default?.debug ?? false, - catch: Default?.catch ?? false - }) + /** Logify for internal errors and debug logs */ static #dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }) /** Tempo state for the global configuration */ static #global = {} as Internal.State /** cache for next-available 'usr' Token key */ static #usrCount = 0; - /** mutable list of registered term plugins */ static get #terms(): t.TermPlugin[] { return getRuntime().pluginsDb.terms } - /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); + /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } + /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); /** flag to prevent recursion during init */ static #lifecycle = { bootstrap: true, initialising: false, extendDepth: 0, ready: false }; /** 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(); @@ -273,7 +259,6 @@ export class Tempo { const mergedOptions: t.Options = storeKey ? Object.assign(Tempo.readStore(storeKey), providedOptions) : providedOptions; - if (shape === Tempo.#global) // sanitize global configuration omit(mergedOptions, 'value', 'anchor', 'result'); @@ -310,6 +295,7 @@ export class Tempo { case 'layout': case 'event': case 'period': + case 'ignore': // lazy-shadowing: only create local object if it doesn't already exist on local shape if (!hasOwn(shape.parse, optKey)) shape.parse[optKey] = create(shape.parse, optKey); @@ -322,8 +308,14 @@ export class Tempo { : isRegExp(v) ? v.source : v ) } else { - asArray(arg.value as Event | Period) - .forEach(elm => ownEntries(elm).forEach(([key, val]) => (rule as Record)[key] = val)) + asArray(arg.value) + .forEach(elm => { + if (isObject(elm)) { + Object.assign(rule, elm); + } else if (isString(elm)) { + rule[elm] = elm; + } + }) } break; @@ -344,7 +336,7 @@ export class Tempo { break; case 'timeZone': { - const zone = String(arg.value).toLowerCase() as t.TIMEZONE; + const zone = arg.value.toString().toLowerCase(); setProperty(shape.config, 'timeZone', enums.TIMEZONE[zone] ?? arg.value); break; } @@ -372,7 +364,7 @@ export class Tempo { case 'mode': shape.parse.mode = optVal as any; - shape.parse.lazy = (optVal === Tempo.MODE.Defer); // if defer, set lazy true. if strict, set lazy false. if auto, constructor will decide. + shape.parse.lazy = (optVal === enums.MODE.Defer); // if defer, set lazy true. if strict, set lazy false. if auto, constructor will decide. break; case 'anchor': @@ -394,7 +386,7 @@ export class Tempo { if (isDefined(shape.parse.event)) Tempo.#setEvents(shape); if (isDefined(shape.parse.period)) Tempo.#setPeriods(shape); - Tempo.#setPatterns(shape); // setup Regex DateTime patterns + setPatterns(shape); // setup Regex DateTime patterns } /** setup mdy TimeZones, using Intl.Locale */ @@ -428,7 +420,7 @@ export class Tempo { // 2. Process Terms if ((discovery as any).term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray((discovery as any).term)]; - Tempo.#dbg.warn(shape.config, 'Tempo: Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + Tempo.#dbg.warn(shape.config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } if (discovery.terms) this.extend(asArray(discovery.terms)); @@ -445,47 +437,34 @@ export class Tempo { // 4. Process Options let opts = discovery.options || {} - return isFunction(opts) ? opts() : opts; - } + if (discovery.ignore) { + const ignore = isFunction(discovery.ignore) ? discovery.ignore() : discovery.ignore; + opts = { ...opts, ignore }; + } + const res = isFunction(opts) ? opts() : opts; - /** build RegExp patterns */ - static #setPatterns(shape: Internal.State) { - const snippet = shape.parse.snippet; - - // 1. ensure numeric snippets are current - const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); // escape each key - const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); - snippet[Token.nbr] = nbr; - snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); - snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep].source}?`); - - // ensure we have our own Map to mutate (shadow if local) - if (!hasOwn(shape.parse, 'pattern')) - shape.parse.pattern = new Map(shape.parse.pattern); // preserve inherited entries while shadowing - - const layouts = { ...shape.parse.layout }; // shallow-copy to include inherited properties - for (const [sym, layout] of ownEntries(layouts, true)) { - const reg = Tempo.regexp(layout, snippet); - shape.parse.pattern.set((sym as symbol), reg); // merge/update compiled RegExp + if (shape === Tempo.#global) { + Tempo.#buildGuard(); + setPatterns(shape); } - if (shape === Tempo.#global) - Tempo.#buildGuard(); // build the high-performance 'Master Guard' ONLY for global changes + return res; } static #buildGuard() { // Tempo.#dbg.error(Tempo.#global.config, 'Building Guard...'); const wordsList = [ - ...ownKeys(enums.NUMBER), - ...ownKeys(enums.WEEKDAY), - ...ownKeys(enums.WEEKDAYS), - ...ownKeys(enums.MONTH), - ...ownKeys(enums.MONTHS), - ...ownKeys(enums.DURATION), - ...ownKeys(enums.DURATIONS), - ...ownKeys(enums.TIMEZONE), + ...Object.keys(enums.NUMBER), + ...Object.keys(enums.WEEKDAY), + ...Object.keys(enums.WEEKDAYS), + ...Object.keys(enums.MONTH), + ...Object.keys(enums.MONTHS), + ...Object.keys(enums.DURATION), + ...Object.keys(enums.DURATIONS), + ...Object.keys(enums.TIMEZONE), ...ownKeys(Tempo.#global.parse.event), ...ownKeys(Tempo.#global.parse.period), + ...ownKeys(Tempo.#global.parse.ignore), ...ownKeys(Tempo.#global.parse.snippet), ...ownKeys(Tempo.#global.parse.layout), ...[Token.slk], @@ -509,7 +488,6 @@ export class Tempo { let i = 0; const len = input.length; - // console.log(`Guard testing: "${input}"`); while (i < len) { const char = input[i]; @@ -538,7 +516,6 @@ export class Tempo { for (let l = searchLen; l > 0; l--) { const candidate = slice.substring(0, l); if (Tempo.#allowedTokens.has(candidate)) { - // console.log(` Matched token: "${candidate}" at ${i}`); i += l; matched = true; break; @@ -579,14 +556,14 @@ export class Tempo { * @param plugin - A plugin or term extension to register. * @param options - Optional configuration for the plugin. */ - static extend(plugin: t.Plugin, options?: t.Options): typeof Tempo; + static extend(plugin: Plugin, options?: t.Options): typeof Tempo; /** * Register an array of plugins or term extensions. * * @param plugins - An array of plugins, terms, or extensions to register. * @param options - Optional configuration for the plugins. */ - static extend(plugins: (t.Plugin | t.TermPlugin | any)[], options?: t.Options): typeof Tempo; + static extend(plugins: (Plugin | TermPlugin | any)[], options?: t.Options): typeof Tempo; /** * Register multiple plugins or term extensions. * @@ -631,12 +608,12 @@ export class Tempo { rt.installed.add(name); registerPlugin(item); - (item as t.Plugin).install.call(this as any, this); + (item as Plugin).install.call(this as any, this); } else if (isObject(item)) { // 1. handle TermPlugin if (isString((item as any).key) && isFunction((item as any).define)) { - const config = item as t.TermPlugin; + const config = item as TermPlugin; if (Tempo.#termMap.has(config.key)) return; Tempo.#termMap.set(config.key, config); @@ -664,7 +641,7 @@ export class Tempo { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(Tempo.#global.config, 'Tempo: Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + Tempo.#dbg.warn(Tempo.#global.config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } if (discovery.options) Tempo.#setConfig(Tempo.#global, discovery.options) if (discovery.plugins) this.extend(discovery.plugins, discovery.options) @@ -697,7 +674,8 @@ export class Tempo { } if (Tempo.#lifecycle.extendDepth === 0) { - Tempo.#setPatterns(Tempo.#global); // rebuild the global patterns + Tempo.#buildGuard(); + setPatterns(Tempo.#global); // rebuild the global patterns } return this; @@ -709,34 +687,39 @@ export class Tempo { Tempo.#lifecycle.initialising = true; try { - const { timeZone, calendar } = getDateTimeFormat(); - - // 1. Establish the base parsing state - Tempo.#global.parse = markConfig({ - snippet: Object.assign({}, Snippet), - layout: Object.assign({}, Layout), - event: Object.assign({}, Event), - period: Object.assign({}, Period), - mdyLocales: Tempo.#mdyLocales(Default.mdyLocales as t.Options['mdyLocales']), - mdyLayouts: asArray(Default.mdyLayouts as t.Options['mdyLayouts']) as t.Pair[], - pivot: Default.pivot, - mode: Default.mode as any, - lazy: false, - }) as Internal.Parse; - - // 2. Establish the base configuration options - Tempo.#global.config = markConfig(Object.create(Default)); - Object.defineProperties(Tempo.#global.config, { - calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, - timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, - locale: { value: Tempo.#locale(), enumerable: true, writable: true, configurable: true }, - discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, - formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, - sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, - get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, - scope: { value: 'global', enumerable: true, writable: true, configurable: true }, - catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } - }); + const rt = getRuntime(); + rt.state = undefined; // force fresh state + const state = init(); + Tempo.#global = state; + + // 1. Augment the parsing state (non-destructively) + const parse = Tempo.#global.parse; + parse.pattern ??= new Map(); + parse.mdyLocales = Tempo.#mdyLocales(Default.mdyLocales as t.Options['mdyLocales']); + parse.mdyLayouts = asArray(Default.mdyLayouts as t.Options['mdyLayouts']) as t.Pair[]; + parse.pivot ??= Default.pivot as any; + parse.mode ??= Default.mode as any; + parse.lazy = false; + + // 2. Establish context and keys + const sys = getDateTimeFormat(); + const timeZone = options.timeZone ?? sys.timeZone; + const calendar = options.calendar ?? sys.calendar; + const config = Tempo.#global.config; + const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; + const storeKey = options.store || config.store || Symbol.keyFor(sym.$Tempo) as string; + const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; + + // Resolve locale if missing or invalid + const currentLocale = config.locale; + const locale = (!currentLocale || currentLocale === 'en-US') ? Tempo.#locale(currentLocale) : currentLocale; + + if (!hasOwn(config, 'get')) { + Object.defineProperty(config, 'get', { + value: function (key: string) { return this[key] }, + enumerable: false, writable: true, configurable: true + }); + } Tempo.#usrCount = 0; // reset user-key counter for (const key of Object.keys(Token)) // purge user-allocated Tokens @@ -746,17 +729,21 @@ export class Tempo { Tempo.#termMap.clear(); // clear term lookup map registryReset(); // purge formats and numbers - const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; - const storeKey = Symbol.keyFor(sym.$Tempo) as string; - - const rt = getRuntime(); - const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; - + // 3. Apply configuration via unified setters (non-destructive merge) Tempo.#setConfig(Tempo.#global, + { + calendar, + timeZone, + locale, + discovery: storeKey, + formats: config.formats ?? enumify(STATE.FORMAT, false), + scope: 'global', + catch: options.catch ?? config.catch ?? false + }, { store: storeKey, discovery: storeKey, scope: 'global' }, Tempo.readStore(storeKey), // allow for storage-values to overwrite Tempo.#setDiscovery(Tempo.#global, rt.pluginsDb as any), // persistent library extensions - Tempo.#setDiscovery(Tempo.#global, userDiscovery), // user Discovery (Configuration bootstrapping) + Tempo.#setDiscovery(Tempo.#global, userDiscovery), // user Discovery (Configuration bootstrapping) options, // explicit options from the call ) @@ -766,7 +753,7 @@ export class Tempo { Tempo.#dbg.info(Tempo.config, 'Tempo:', Tempo.#global.config); Tempo.#lifecycle.ready = true; - Tempo.#setPatterns(Tempo.#global); // rebuild the global patterns (Master Guard etc) + setPatterns(Tempo.#global); // rebuild the global patterns (Master Guard etc) } finally { Tempo.#lifecycle.initialising = false; @@ -805,48 +792,7 @@ export class Tempo { /** @internal translates {layout} into an anchored, case-insensitive RegExp. */ static regexp(layout: string | RegExp, snippet?: Snippet) { - // helper function to replace {name} placeholders with their corresponding snippets - function matcher(str: string | RegExp, depth = 0): string { - if (depth > 12) return isRegExp(str) ? str.source : str; // depth guard - - let source = isRegExp(str) ? str.source : str; - - if (isRegExpLike(source)) // string that looks like a RegExp - source = source.substring(1, source.length - 1); // remove the leading/trailing "/" - if (source.startsWith('^') && source.endsWith('$')) - source = source.substring(1, source.length - 1); // remove the leading/trailing anchors (^ $) - - return source.replaceAll(new RegExp(Match.braces), (match, name) => { // iterate over "{}" pairs in the source string - const token = Tempo.getSymbol(name); // get the symbol for this {name} - const customs = snippet?.[token as keyof Snippet]?.source ?? snippet?.[name as keyof Snippet]?.source; - const globals = Tempo.#global.parse.snippet[token as keyof Snippet]?.source ?? Tempo.#global.parse.snippet[name as keyof Snippet]?.source; - const layout = Layout[token as keyof Layout]; // get resolution source (layout) - - let res = customs ?? globals ?? layout; // get the snippet/layout source - - if (isNullish(res) && name.includes('.')) { // if no definition found, try fallback - const prefix = name.split('.')[0]; // get the base token name - const pToken = Tempo.getSymbol(prefix); - res = snippet?.[pToken as keyof Snippet]?.source ?? snippet?.[prefix as keyof Snippet]?.source - ?? Snippet[pToken as keyof Snippet]?.source ?? Snippet[prefix as keyof Snippet]?.source - ?? Layout[pToken as keyof Layout]; - } - - if (res && name.includes('.')) { // wrap dotted extensions for identification - const safeName = name.replace(/\./g, '_'); - if (!res.startsWith(`(?<${safeName}>`)) - res = `(?<${safeName}>${res})`; - } - - return (isNullish(res) || res === match) // if no definition found, - ? match // return the original match - : matcher(res, depth + 1); // else recurse to see if snippet contains embedded "{}" pairs - }); - } - - layout = matcher(layout); // initiate the layout-parse - - return new RegExp(`^(${layout})$`, 'i'); // translate the source into a regex + return compileRegExp(layout, Tempo.#global, snippet as any); } /** Compares two `Tempo` instances or date-time values. */ @@ -895,7 +841,7 @@ export class Tempo { static get instant() { return Temporal.Instant.fromEpochNanoseconds(this.now()) } /** static Tempo.terms (registry) */ - static get terms(): Secure[]> & Record> { + static get terms(): Secure[]> & Record> { const list = Tempo.#terms.map(({ define, resolve, ...rest }) => rest); // `delegate` returns an array-like proxy that also supports string lookups; use // an `unknown` bridge to assert the combined intersection type so the compiler @@ -905,7 +851,7 @@ export class Tempo { return list.find(t => t.key === key || t.scope === key); } return undefined; - }) as unknown as Secure[]> & Record>; + }) as unknown as Secure[]> & Record>; } /** static Tempo.formats (registry) */ @@ -930,11 +876,12 @@ export class Tempo { static get parse() { const parse = Tempo.#global.parse; return secure({ - ...parse, // spread primitives like {pivot} + ...omit(parse, 'token'), // spread primitives like {pivot} snippet: { ...parse.snippet }, // spread nested objects layout: { ...parse.layout }, event: { ...parse.event }, period: { ...parse.period }, + ignore: { ...parse.ignore }, mdyLocales: [...parse.mdyLocales], mdyLayouts: [...parse.mdyLayouts], mode: parse.mode @@ -949,6 +896,11 @@ export class Tempo { /** release global config and reset library to defaults */ static [Symbol.dispose]() { Tempo.init() } + /** static Tempo.ignores (registry) */ + static get ignores(): Secure { + return secure(ownKeys(Tempo.#global.parse.ignore, true)); + } + /** allow instanceof to work across module boundaries via the local brand symbol */ static [sym.$isTempo] = true; static [Symbol.hasInstance](instance: any) { @@ -962,7 +914,7 @@ export class Tempo { static { // Static initialization block to sequence the bootstrap phase // Define the reactive register hook - getRuntime().setHook(sym.$Register, (plugin: t.Plugin | t.Plugin[]) => { + getRuntime().setHook(sym.$Register, (plugin: Plugin | Plugin[]) => { if (!Tempo.isExtending) Tempo.extend(plugin) }); @@ -989,12 +941,10 @@ export class Tempo { /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.MatchResult[] } as Internal.Parse } as Internal.State; - - /** @internal internal key for signaling pre-errored state in constructor */ static [sym.$errored] = sym.$errored; - /** @internal */ static [sym.$termError](config: Internal.Config, term: string): void { + /** @internal */ static [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); @@ -1002,7 +952,7 @@ export class Tempo { } /** @internal */ static get [sym.$dbg](): Logify { return Tempo.#dbg } - /** @internal */ static get [sym.$guard]() { return (Tempo as any).#guard } + /** @internal */ static get [sym.$guard]() { return Tempo.#guard } /** * @internal Internal access to instance private state. @@ -1051,7 +1001,7 @@ export class Tempo { return 'Tempo'; // hard-coded to avoid minification mangling } - get [sym.$isTempo]() { return true } + get [sym.$isTempo](): true { return true } /** * Instantiates a new `Tempo` object with configuration only. @@ -1097,26 +1047,44 @@ export class Tempo { this.#errored = true; } - if (!this.#local.parse.lazy) this.#ensureParsed(); // attempt to interpret immediately (if not lazy) + if (!this.#local.parse.lazy) this.#resolve(); // attempt to interpret immediately (if not lazy) } - /** Ensure the instance has been parsed (for deferred execution) */ - #ensureParsed() { - if (this.#zdt) return; - try { - this.#zdt = this.#parse(this.#tempo as t.DateTime, this.#anchor); - secure(this.#local.config); - const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result].filter(v => v !== undefined); - secure(this.#local.parse, new WeakSet(skip as any)); - } catch (err) { - const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; - if (this.#local.config.catch === true) { - 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; + /** Resolve the instance to a Temporal.ZonedDateTime (with optional callback) */ + #resolve(cb?: (zdt: Temporal.ZonedDateTime) => T): T | Temporal.ZonedDateTime { + if (!this.#zdt) { + try { + const skip = [this.#local.parse.format, this.#local.parse.term, this.#local.parse.result] + .filter(isDefined); + this.#zdt = this.#parse(this.#tempo as t.DateTime, this.#anchor); + if (isUndefined(this.#zdt)) { + this.#errored = true; + const msg = `Tempo parse returned undefined for: ${String(this.#tempo)}`; + if (this.#local.config.catch === true) { + Tempo.#dbg.error(this.#local.config, msg); + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); + } else { + Tempo.#dbg.error(this.#local.config, msg); + throw new Error(msg); + } + } + secure(this.#local.config); + secure(this.#local.parse, new WeakSet(skip)); + } catch (err) { + this.#errored = true; // mark as errored + const msg = `Cannot create Tempo: ${(err as Error).message}\n${(err as Error).stack}`; + if (this.#local.config.catch === true) { + Tempo.#dbg.error(this.#local.config, msg); // log as error if in catch-mode + this.#zdt = this.#now.toZonedDateTimeISO('UTC'); + } else { + Tempo.#dbg.error(this.#local.config, err, msg); // log as error then re-throw + throw err; + } } } + + const zdt = isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC'); + return cb?.(zdt) ?? zdt; } #setLazy(target: any, name: PropertyKey | undefined, define: (keyOnly: boolean) => any, isKeyOnly = false) { @@ -1177,9 +1145,8 @@ export class Tempo { // discovery phase if (host === 'fmt') { if (!ensureModule(this, 'FormatModule')) return undefined; - if (isDefined(this.#local.config.formats[key])) { + if (isDefined(this.#local.config.formats[key])) return this.#setLazy(target, key, () => this.format(key as t.Format))?.(); - } } else { if (!ensureModule(this, 'TermsModule')) return undefined; const term = Tempo.#termMap.get(key); @@ -1259,11 +1226,7 @@ export class Tempo { /** Full weekday name (e.g., 'Monday') */ get wkd() { return Tempo.WEEKDAYS.keyOf(this.toDateTime().dayOfWeek as t.Weekday) } /** ISO weekday number: Mon=1, Sun=7 */ get dow() { return this.toDateTime().dayOfWeek as t.Weekday } /** Nanoseconds since Unix epoch (BigInt) */ get nano() { return this.toDateTime().epochNanoseconds } - /** `true` if the underlying date-time is valid. */ - get isValid() { - this.#ensureParsed(); - return isZonedDateTime(this.#zdt) && !this.#errored; - } + /** `true` if the underlying date-time is valid. */ get isValid() { return this.#resolve(zdt => !this.#errored && isZonedDateTime(zdt)); } /** list of registered terms and their available range keys */ get terms(): Record { @@ -1317,16 +1280,16 @@ export class Tempo { /** Instance-specific parse rules (merged with global) */ get parse(): Internal.Parse { const self: Tempo = (this as any)[lib.$Target] ?? this; - self.#ensureParsed(); + self.#resolve(); // Return a shadowed view so we can safely inject matches without breaking the freeze on the original state const out = Object.create(self.#local.parse); - if (self.#matches !== undefined) { + if (self.#matches !== undefined) Object.defineProperty(out, 'result', { value: self.#matches, enumerable: true, configurable: true }); - } + return out as t.Internal.Parse; } - /** Keyed results for all resolved terms */ get term() { return this.#term } + /** 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({ @@ -1345,28 +1308,16 @@ export class Tempo { */ /** @internal */ get #Tempo() { return this.constructor as typeof Tempo; } - format(fmt: K) { - this.#ensureParsed(); - return interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt); - } + /** apply a custom format. */ format(fmt: K) { return this.#resolve(() => interpret(this, 'FormatModule', () => `{${String(fmt)}}`, false, fmt)); } - /** time duration until another date-time */ until(arg0?: any, arg1?: any): any { this.#ensureParsed(); return interpret(this, 'DurationModule', undefined, false, 'until', arg0, arg1) ?? this; } - /** time elapsed since another date-time */ since(arg0?: any, arg1?: any): any { this.#ensureParsed(); return interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1) ?? this; } + /** time duration until another date-time */ until(arg0?: any, arg1?: any): any { return this.#resolve(() => interpret(this, 'DurationModule', undefined, false, 'until', arg0, arg1) ?? this); } + /** time elapsed since another date-time */ since(arg0?: any, arg1?: any): any { return this.#resolve(() => interpret(this, 'DurationModule', undefined, false, 'since', arg0, arg1) ?? this); } - /** returns a new `Tempo` with specific duration added. */add(tempo?: t.Add, options?: t.Options): Tempo { this.#ensureParsed(); return interpret(this, 'MutateModule', '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 new `Tempo` with specific duration added. */add(tempo?: t.MutateAdd, options?: t.Options): Tempo { return this.#resolve(() => interpret(this, 'MutateModule', 'add', false, tempo, options) ?? this); } + /** returns a new `Tempo` with specific offsets. */ set(tempo?: t.MutateSet, options?: t.Options): Tempo { return this.#resolve(() => interpret(this, 'MutateModule', 'set', false, tempo, options) ?? this); } /** returns a clone of the current `Tempo` instance. */ clone() { return new this.#Tempo(this, this.config) } - /** returns the underlying Temporal.ZonedDateTime */ - toDateTime(): Temporal.ZonedDateTime { - try { - this.#ensureParsed(); - return isZonedDateTime(this.#zdt) ? this.#zdt : this.#now.toZonedDateTimeISO('UTC'); - } catch (err) { - if (this.#local.config.catch === true) return this.#now.toZonedDateTimeISO('UTC'); - throw err; - } - } + /** returns the underlying Temporal.ZonedDateTime */ toDateTime() { return this.#resolve() as Temporal.ZonedDateTime; } /** returns a Temporal.PlainDate representation */ toPlainDate() { return this.toDateTime().toPlainDate() } /** returns a Temporal.PlainTime representation */ toPlainTime() { return this.toDateTime().toPlainTime() } /** returns a Temporal.PlainDateTime representation */ toPlainDateTime() { return this.toDateTime().toPlainDateTime() } @@ -1402,7 +1353,7 @@ export class Tempo { const res = interpret(this, 'ParseModule', 'parse', false, tempo, dateTime, term); if (isUndefined(res)) { - const msg = `Tempo: ParseModule error. Could not parse ${String(tempo)}`; + const msg = `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; @@ -1430,10 +1381,10 @@ export class Tempo { /** resolve constructor / method arguments */ #swap(tempo?: t.DateTime | t.Options, options: t.Options = {}): [t.DateTime | undefined, t.Options] { - if (isTempo(tempo)) { + if (isTempo(tempo)) // preserve parse result history when creating new instance from an existing one return [tempo, Object.assign({ result: [...tempo.parse.result] }, options)]; - } + return this.#isOptions(tempo) ? [tempo.value, Object.assign({}, tempo)] : [tempo, options]; @@ -1499,17 +1450,12 @@ export namespace Tempo { export interface BaseOptions extends t.Internal.BaseOptions { } export type Options = t.Options; - export type TermPlugin = t.TermPlugin; - export type Plugin = t.Plugin; - export type Module = t.Module; - export type Extension = t.Extension; - /** Configuration to use for #until() and #since() argument */ export type Unit = t.Unit; export type Until = t.Until; export type Mutate = t.Mutate; - export type Set = t.Set; - export type Add = t.Add; + export type Set = t.MutateSet; + export type Add = t.MutateAdd; export type OwnFormat = t.OwnFormat; export type Formats = t.Formats; @@ -1517,8 +1463,6 @@ export namespace Tempo { export type FormatRegistry = t.FormatRegistry; export type FormatType = t.FormatType; - export type Terms = t.Terms; - export type Modifier = t.Modifier; export type Relative = t.Relative; diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index 770fe66b..66d0b8b0 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -6,13 +6,20 @@ import { MutateModule } from '#tempo/mutate'; import { DurationModule } from '#tempo/duration'; import { FormatModule } from '#tempo/format'; import { TermsModule } from '#tempo/term'; +import { getRuntime } from '#tempo/support'; // Batteries Included: Register standard modules const core = [ParseModule, MutateModule, FormatModule, DurationModule, TermsModule]; -onRegistryReset(() => { Tempo.extend(core); }); +getRuntime().modules['Tempo'] = Tempo; +onRegistryReset(() => { + getRuntime().modules['Tempo'] = Tempo; + Tempo.extend(core); +}); Tempo.extend(core); export * from './tempo.class.js'; +export { parse } from './tempo.parse.js'; export { enums }; +export default Tempo; diff --git a/packages/tempo/src/tempo.parse.ts b/packages/tempo/src/tempo.parse.ts new file mode 100644 index 00000000..d3a41637 --- /dev/null +++ b/packages/tempo/src/tempo.parse.ts @@ -0,0 +1,40 @@ +import { markConfig } from '#library/symbol.library.js'; +import { getRuntime } from './support/tempo.runtime.js'; +import { init, extendState } from './support/tempo.init.js'; +import { ParseEngine } from './plugin/module/module.parse.js'; +import { setPatterns } from './support/tempo.util.js'; +import type { DateTime, Options, Internal } from './tempo.type.js'; + +export * from './plugin/module/module.parse.js'; + +/** + * Standalone Smart Parser + * Returns a native Temporal.ZonedDateTime without requiring the full Tempo class. + * + * @example + * import { parse } from '@magmacomputing/tempo/parse'; + * const zdt = parse('tomorrow', { timeZone: 'Europe/Paris' }); + */ +export function parse(value: DateTime, options: Options = {}): Temporal.ZonedDateTime { + const runtime = getRuntime(); + const globalState = runtime.state ?? init(); + + // Create a local state shadowed from the global state + const state: Internal.State = { + config: markConfig(Object.create(globalState.config)), + parse: markConfig(Object.create(globalState.parse)) + } as Internal.State; + + // Standalone parsing defaults to 'strict' mode + const localOptions = { ...options }; + localOptions.mode ??= 'strict'; + + // Apply options + extendState(state, localOptions); + + // Compile RegEx patterns + setPatterns(state); + + // Execute the parse + return ParseEngine.parse(state, value); +} diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 6e1b1af0..e9e62fa5 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,16 +7,12 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ +import { sym } from '#tempo/support/tempo.symbol.js'; import * as enums from '#tempo/support/tempo.enum.js'; -import sym from '#tempo/support/tempo.symbol.js'; -import type { Snippet, Layout, Event, Period, Token } from '#tempo/support/tempo.default.js'; +import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/tempo.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue } from '#library/type.library.js'; import type { Range, TermPlugin, ResolvedRange, Plugin, Terms, Module, Extension } from '#tempo/plugin/plugin.type.js'; - -/** - * Structural forward-reference to the Tempo class. - * 'import type' is safe for circular ESM references โ€” erased at runtime. -*/ +import type { Token } from '#tempo/support/tempo.symbol.js'; import type { Tempo } from '#tempo/tempo.class.js'; declare global { @@ -48,7 +44,6 @@ export type Options = Prettify<{ [K in keyof Internal.BaseOptions]?: Internal.Ba * Every attempt to resolve an input to a Tempo should always be checked with .isValid before continuing. * Otherwise unpredictable behaviour is likely. */ -export type { Plugin, Module, Extension }; /** Configuration to use for #until() and #since() argument */ export type DateTimeUnit = Temporal.DateUnit | Temporal.TimeUnit @@ -81,12 +76,12 @@ export type SetFields = { } & { [K in 'date' | 'time' | 'event' | 'period']?: string; } -export type Set = Prettify | DateTime export type AddUnits = { [K in Unit]?: number }; -export type Add = Prettify | DateTime +export type MutateAdd = Prettify | DateTime export type Modifier = '=' | '-' | '+' | '<' | '<=' | '-=' | '>' | '>=' | '+=' | 'this' | 'next' | 'prev' | 'last' | 'first' | undefined export type Relative = 'ago' | 'hence' | 'prior' | 'from now' @@ -114,8 +109,6 @@ export type Format = enums.Format; export type FormatRegistry = enums.FormatEnum; export type FormatType = enums.FormatType; -export type { Range, TermPlugin, ResolvedRange, Terms }; - export type WEEKDAY = enums.WEEKDAY export type WEEKDAYS = enums.WEEKDAYS export type MONTH = enums.MONTH @@ -170,10 +163,11 @@ export namespace Internal { /** patterns to help parse value */ layout: Layout | PatternOption; /** custom date aliases (events). */ event: Event | PatternOption; /** custom time aliases (periods). */ period: Period | PatternOption; + /** noise words to ignore during parsing. */ ignore: Ignore; /** custom format strings to merge in the FORMAT enum */formats: Property; /** plugins to be automatically extended */ plugins: Plugin | Plugin[]; /** supplied value to parse */ value: DateTime; - /** @internal temporary anchor used during parsing */ anchor: Temporal.ZonedDateTime; + /** @internal temporary anchor used during parsing */ anchor: any; /** @internal accumulated parse results */ result?: Match[] | undefined; } @@ -189,6 +183,13 @@ export namespace Internal { export interface State { // 'global' and 'local' variables /** current defaults for all Tempo instances */ config: Config; /** parsing rules */ parse: Parse; + /** @internal current valid configuration options */ OPTION: Set; + /** @internal valid Temporal units for ZonedDateTime */ ZONED_DATE_TIME: Set; + + /** @internal current recursion depth during parsing */ parseDepth?: number; + /** @internal current matches during parsing */ matches?: Match[] | undefined; + /** @internal current anchor during parsing */ anchor?: Temporal.ZonedDateTime; + /** @internal has the parse operation errored? */ errored?: boolean; } /** debug a Tempo instantiation */ @@ -201,7 +202,7 @@ export namespace Internal { /** Debugging results of a parse operation. See `doc/tempo.api.md`. */ export interface Parse { - /** Locales which prefer 'mm-dd-yyyy' date-order */ mdyLocales: { locale: string, timeZones: string[] }[]; + /** Locales which prefer 'mm-dd-yyyy' date-order */ mdyLocales: ({ locale: string, timeZones: string[] } | string)[]; /** Layout names that are switched to mdy */ mdyLayouts: Pair[]; /** is a timeZone that prefers 'mmddyyyy' date order */ isMonthDay?: boolean; /** Symbol registry */ token: Token; @@ -210,6 +211,7 @@ export namespace Internal { /** Map of regex-patterns to match input-string */ pattern: Registry; /** configured Events */ event: Event; /** configured Periods */ period: Period; + /** noise words to ignore during parsing */ ignore: Record; /** pivot year for two-digit years */ pivot: number; /** parsing match result */ result: Match[]; /** was this a nested/anchored parse? */ isAnchored?: boolean; @@ -218,10 +220,11 @@ export namespace Internal { /** @internal lazy delegator for formats */ format?: any; /** @internal lazy delegator for terms */ term?: any; /** @internal localized Master Guard scanner */ guard?: { test(str: string): boolean }; + /** @internal localized Noise Word scanner */ ignorePattern?: RegExp; } /** drop the parse-only Options */ - export type OptionsKeep = Omit + export type OptionsKeep = Omit /** Instance configuration derived from supply, storage, and discovery. */ export interface Config extends Required> { @@ -237,6 +240,7 @@ export namespace Internal { /** aliases to merge in the Number-Word dictionary */ numbers?: Record; /** term plugins to be registered via Tempo.addTerm() */terms?: TermPlugin | TermPlugin[]; /** custom format strings to merge in the FORMAT dictionary */formats?: Property; + /** noise words to ignore during parsing via Tempo.ignore() */ ignore?: Ignore /** plugins to be automatically extended via Tempo.extend() */plugins?: Plugin | Plugin[]; } } diff --git a/packages/tempo/test/plugin.test.ts b/packages/tempo/test/plugin.test.ts index 88ac7e2f..334b7891 100644 --- a/packages/tempo/test/plugin.test.ts +++ b/packages/tempo/test/plugin.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import type { Plugin } from '#tempo/tempo.type.js'; +import type { Plugin } from '#tempo/plugin/plugin.type.js'; describe('Tempo Plugin System', () => { diff --git a/packages/tempo/test/reactive_registration.test.ts b/packages/tempo/test/reactive_registration.test.ts index ece70182..1ae557a3 100644 --- a/packages/tempo/test/reactive_registration.test.ts +++ b/packages/tempo/test/reactive_registration.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import type { Plugin } from '#tempo/tempo.type.js'; +import type { Plugin } from '#tempo/plugin/plugin.type.js'; describe('Tempo Reactive Registration', () => { test('late-imported plugin is automatically registered', async () => { diff --git a/packages/tempo/test/runtime_brand.test.ts b/packages/tempo/test/runtime_brand.test.ts index fb4d63ba..c85e56ce 100644 --- a/packages/tempo/test/runtime_brand.test.ts +++ b/packages/tempo/test/runtime_brand.test.ts @@ -1,4 +1,4 @@ -import sym, { getRuntime, TempoRuntime } from '#tempo/support'; +import { sym, getRuntime, TempoRuntime } from '#tempo/support'; describe('TempoRuntime Cross-Bundle Adoption', () => { /** diff --git a/packages/tempo/test/standalone_parse.test.ts b/packages/tempo/test/standalone_parse.test.ts new file mode 100644 index 00000000..bc5a1c32 --- /dev/null +++ b/packages/tempo/test/standalone_parse.test.ts @@ -0,0 +1,60 @@ +import { parse } from '#tempo/parse'; +import { Tempo } from '#tempo'; +import { Temporal } from '@js-temporal/polyfill'; +import { registryReset } from '#tempo/support'; + +beforeEach(() => { + registryReset(); +}); + +test('standalone parse: tomorrow', () => { + const now = Temporal.Now.zonedDateTimeISO(); + const zdt = parse('tomorrow', { anchor: now }); + expect(zdt).toBeInstanceOf(Temporal.ZonedDateTime); + + const tomorrow = now.add({ days: 1 }).startOfDay(); + + expect(zdt.year).toBe(tomorrow.year); + expect(zdt.month).toBe(tomorrow.month); + expect(zdt.day).toBe(tomorrow.day); +}); + +test('standalone parse: with options', () => { + const zdt = parse('10:00', { timeZone: 'America/New_York' }); + expect(zdt.timeZoneId).toBe('America/New_York'); + expect(zdt.hour).toBe(10); + expect(zdt.minute).toBe(0); +}); + +test('standalone parse: strict mode (ambiguous input)', () => { + // In strict mode, '20' alone might be ambiguous or invalid depending on context + // Actually, '20' is often parsed as a day if it's the only thing, but strict mode might reject it if it's not clearly a date. + + // Let's try something that requires 'strict' mode to fail or behave differently. + // Actually, the goal is just to ensure it works and returns ZonedDateTime. + + const zdt = parse('2025-05-20'); + expect(zdt.year).toBe(2025); + expect(zdt.month).toBe(5); + expect(zdt.day).toBe(20); +}); + +test('standalone parse: shared state with Tempo class', () => { + // Register a custom event via Tempo + Tempo.init({ + event: { + 'party-time': '2025-12-31T23:59:59' + } + }); + + const zdt = parse('party-time'); + expect(zdt.year).toBe(2025); + expect(zdt.month).toBe(12); + expect(zdt.day).toBe(31); + expect(zdt.hour).toBe(23); +}); + +test('standalone parse: timezone lookup', () => { + const zdt = parse('2025-05-20 10:00', { timeZone: 'pst' }); + expect(zdt.timeZoneId).toBe('America/Los_Angeles'); +}); diff --git a/packages/tempo/test/storage.test.ts b/packages/tempo/test/storage.test.ts index ed483ba9..5a1f9ad9 100644 --- a/packages/tempo/test/storage.test.ts +++ b/packages/tempo/test/storage.test.ts @@ -1,4 +1,5 @@ import { Tempo } from '#tempo'; +import { registryReset } from '#tempo/support'; import type { Options } from '#tempo/tempo.type.js'; describe('Tempo storage functionality', () => { @@ -11,8 +12,8 @@ describe('Tempo storage functionality', () => { delete (globalThis as any)[Symbol.for(testDiscovery)]; delete process.env[customKey]; delete process.env[testKey]; - // Reset global config to use test keys - Tempo.init({ store: testKey, discovery: testDiscovery }); + // Reset global registries and runtime state + Tempo.init(); }) it('should write to and read from a custom storage key', () => { diff --git a/packages/tempo/test/tempo_regexp.test.ts b/packages/tempo/test/tempo_regexp.test.ts index ea9ae06d..d660be0f 100644 --- a/packages/tempo/test/tempo_regexp.test.ts +++ b/packages/tempo/test/tempo_regexp.test.ts @@ -1,5 +1,5 @@ import { Tempo } from '#tempo'; -import { Token } from '#tempo/support/tempo.default.js'; +import { Token } from '#tempo/support'; describe('Tempo.regexp', () => { test('should expand snippets and handle nested named capture groups', () => { diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index 37a2ef0f..3333257e 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './dist/plugin/term/standard.index.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\/parse$/, replacement: resolve(__dirname, './dist/tempo.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') }, @@ -47,7 +47,7 @@ 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\/parse$/, replacement: resolve(__dirname, './src/tempo.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') },