diff --git a/package-lock.json b/package-lock.json index 5050eece..1c24b0c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9930,7 +9930,7 @@ }, "packages/library": { "name": "@magmacomputing/library", - "version": "3.0.3", + "version": "3.1.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" @@ -9941,14 +9941,14 @@ }, "packages/tempo": { "name": "@magmacomputing/tempo", - "version": "3.0.3", + "version": "3.1.0", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "3.0.3", + "@magmacomputing/library": "3.1.0", "@rollup/plugin-alias": "^6.0.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", diff --git a/package.json b/package.json index fcda815e..ac375a52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "3.0.3", + "version": "3.1.0", "private": true, "engines": { "node": ">=20.0.0" @@ -59,4 +59,4 @@ "edgedriver@6.3.0": true, "geckodriver@6.1.0": true } -} +} \ No newline at end of file diff --git a/packages/library/package.json b/packages/library/package.json index 016a6e6a..c90fcb9f 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "3.0.3", + "version": "3.1.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/common/international.library.ts b/packages/library/src/common/international.library.ts index 78b1de81..e2691989 100644 --- a/packages/library/src/common/international.library.ts +++ b/packages/library/src/common/international.library.ts @@ -13,8 +13,8 @@ const getLF = memoizeFunction((locale?: string, type: Intl.ListFormatType = 'con }); /** memoized helper for Intl.DateTimeFormat instances */ -const getDTF = memoizeFunction((locale?: string) => { - return new Intl.DateTimeFormat(locale); +export const getDTF = memoizeFunction((locale?: string, options?: Intl.DateTimeFormatOptions) => { + return new Intl.DateTimeFormat(locale, options); }); /** memoized helper for Intl.NumberFormat instances */ @@ -43,12 +43,13 @@ export function getDateTimeFormat() { return getDTF().resolvedOptions(); } -/** return the canonicalized locale string */ -export function canonicalLocale(locale: string) { +/** return the canonicalized locale string, or undefined if invalid */ +export function canonicalLocale(locale: string): string | undefined { try { return Intl.getCanonicalLocales(locale.replace(/_/g, '-'))[0]; } catch (e) { - return locale; + console.warn(`[Tempo] dropping invalid locale: '${locale}'`, e); + return undefined; } } @@ -84,6 +85,16 @@ export function formatNumber(value: number, locale?: string, options?: Intl.Numb } } +/** return a localized day period string (e.g., 'AM', 'PM', 'de la mañana') */ +export function formatDayPeriod(value: number, locale?: string, options?: Intl.DateTimeFormatOptions) { + try { + const parts = getDTF(locale, options).formatToParts(value); + return parts.find(p => p.type === 'dayPeriod')?.value; + } catch (e) { + return undefined; + } +} + /** return a localized unit string (e.g., '2 days') */ export function formatUnit(value: number, unit: string, locale?: string, unitDisplay: Intl.NumberFormatOptions['unitDisplay'] = 'long') { try { diff --git a/packages/library/src/common/string.library.ts b/packages/library/src/common/string.library.ts index 499941e3..ab7d6d7c 100644 --- a/packages/library/src/common/string.library.ts +++ b/packages/library/src/common/string.library.ts @@ -14,23 +14,32 @@ import { isString, isObject, isNumeric, assertCondition, assertString } from '#l */ export function trimAll(str: string | number, pat?: RegExp) { return str - .toString() // coerce to String + .toString() // coerce to String .replace(pat!, '') // remove regexp, if supplied .replace(/\t/g, ' ') // replace with .replace(/(\r\n|\n|\r)/g, ' ') // replace & .replace(/\s{2,}/g, ' ') // trim multiple - .trim() // leading/trailing + .trim() // leading/trailing } /** every word has its first letter capitalized */ export function toProperCase(...str: T[]) { return str - .flat() // in case {str} was already an array + .flat() // in case {str} was already an array .map(text => text.replace(/\w\S*/g, word => word.charAt(0).toUpperCase() + word.substring(1).toLowerCase())) .join(' ') as T } +/** only the first letter of the entire string is capitalized (locale-aware) */ +export function toTitleCase(str: string, locale?: string): string { + try { + return str.charAt(0).toLocaleUpperCase(locale) + str.slice(1).toLocaleLowerCase(locale); + } catch { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + } +} + // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ const PAT = /[A-Z\xC0-\xD6\xD8-\xDE]?[a-z\xDF-\xF6\xF8-\xFF]+|[A-Z\xC0-\xD6\xD8-\xDE]+(?![a-z\xDF-\xF6\xF8-\xFF])|\d+/g; export const toCamelCase = (sentence: T) => { diff --git a/packages/tempo/.vitepress/config.ts b/packages/tempo/.vitepress/config.ts index 3bbf28e7..617fb213 100644 --- a/packages/tempo/.vitepress/config.ts +++ b/packages/tempo/.vitepress/config.ts @@ -40,6 +40,7 @@ export default defineConfig({ text: 'Core Concepts', items: [ { text: 'Configuration', link: '/doc/tempo.config' }, + { text: 'Registries', link: '/doc/tempo.registry' }, { text: 'Smart Parsing', link: '/doc/tempo.parse' }, { text: 'Parse Planner', link: '/doc/tempo.planner' }, { text: 'Regional Parsing (MDY)', link: '/doc/tempo.month-day' }, diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index c2482f8f..c3443c08 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -1,3 +1,4 @@ + # Changelog All notable changes to this project will be documented in this file. @@ -5,6 +6,27 @@ 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). +## [3.1.0] - 2026-06-13 + +### Added +- `registry: { formats, locales }` namespaces to `Tempo.init()` and `Tempo` instance configurations. +- `format: { localize }` namespace to options for toggling localizing formats. +- `Tempo.registry` static accessor for retrieving the active registry configurations. +- **Chained Formatting Modifiers**: Introduced a powerful format modifier engine (e.g. `{mon:locale:upper}`) allowing dynamic casing (`:upper`, `:lower`, `:title`), ordinal suffixes (`:ord`), and deep localization (`:locale`) directly within template strings. +- **Global LOCALE Registry**: Implemented a global `STATE.LOCALE` registry (`Tempo.init({ locale: 'fr-FR', registry: { locales: { ... } } })`) to provide centralized, context-aware translations. +- **Auto-Localization**: Added a `format: { localize: true }` global configuration flag that automatically applies the `:locale` modifier to all formatting tokens behind the scenes. +- **Internationalized Parsing**: Implemented robust localized parsing capabilities natively into Tempo. By opting in via `Tempo.init({ locale: 'fr-FR', parse: { localize: true } })`, Tempo automatically registers localized variants for months, weekdays, and relative terms (yesterday, today, tomorrow) based on your configured `locale`. This engine dynamically strips accents (so "février" and "fevrier" both match safely), ensuring resilient fuzzy-matching for user input out-of-the-box. + +### Deprecated +- Top-level `formats` configuration key. Use `registry: { formats: ... }` instead. +- `Tempo.formats` static accessor. Use `Tempo.registry.formats` instead. + + +### Changed +- **Optimized Intl Instantiation**: Refactored `Intl.DateTimeFormat` and `Intl.RelativeTimeFormat` creation using memoized helpers (`getDTF`, `getRTF`) across the formatting engine, drastically reducing object instantiation overhead during rapid format evaluations. +- **Term Localization Precedence**: Upgraded the Term formatting resolution pipeline to support falling back across a strict precedence: Global Registry > Plugin Bundled Dictionary > term's existing label/value. +- **Plugin Localization Capabilities**: Refactored the `TimeOfDay` plugin (and underlying range resolution logic) to natively bundle and evaluate custom `locale` objects across languages without requiring external overrides in order to demonstrate the new `parse: { localize: true }` capability. + ## [3.0.2] - 2026-06-12 ### Security diff --git a/packages/tempo/doc/release-notes-v3.0.0.md b/packages/tempo/doc/release-notes-v3.0.0.md deleted file mode 100644 index defe1b59..00000000 --- a/packages/tempo/doc/release-notes-v3.0.0.md +++ /dev/null @@ -1,41 +0,0 @@ -# Tempo v3.0.0 Release Notes - -Welcome to Tempo v3.0.0! This major release marks a significant milestone in our architectural journey by finalizing the decentralized plugin ecosystem. - -## 🚀 What's New & Changed - -### Ticker Module Extraction (Breaking Change) -To lighten the core bundle and clean up the API surface for general use cases, the `TickerModule` has been extracted from the base open-source distribution into its own standalone premium plugin (`@magmacomputing/tempo-plugin-ticker`). - -The Ticker is still completely free to use, but it is now protected by a License Key via the Tempo Registry. This allows us to better protect the investment in the advanced scheduling algorithms and restrict its payload footprint strictly to applications that need it. - -### Formatting Module Additions -The `FormatModule` has been updated with new compact date tokens (`{dmy}`, `{mdy}`, `{ymd}`) for generating 8-digit compact date strings (e.g. `24102026`). Additionally, the `{hhmiss}` compact time token has been renamed to `{hms}` for consistency. -We have also introduced **Ordinal Tokens**: uppercase variants of standard date tokens (`{DAY}`, `{WW}`, `{MM}`) now output their ordinal string representation (e.g., `24th`, `1st`, `2nd`). - -### Migration Path for `Tempo.ticker()` Users -If you are upgrading from v2.x and your application relies on `Tempo.ticker()`, you will need to update your integration: - -1. **Install the Plugin**: - ```bash - npm install @magmacomputing/tempo-plugin-ticker - ``` -2. **Activate your License**: Visit [registry.magmacomputing.com.au](https://registry.magmacomputing.com.au) to obtain your free JWT license key. -3. **Register the Plugin**: Wire the key into your application and extend Tempo: - ```javascript - import { Tempo } from '@magmacomputing/tempo'; - import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; - - Tempo.init({ license: 'YOUR_JWT_KEY' }); - Tempo.extend(TickerModule); - ``` - -A migration stub has been left in the core package for v3.0.0. If you accidentally call `Tempo.ticker()` without the plugin installed, the engine will safely throw an informative error directing you to the registry. - -## 🛠️ Internal Improvements -- Bumped core engine to v3.0.0 to reflect the breaking API extraction. -- Fully synchronized build pipelines and TS declarations to ensure `vitest` and `tsc` operate seamlessly across local and premium workspaces. -- Removed legacy `Ticker` shorthands from core test suites for guaranteed separation of concerns. -- **ISO Getter Precision**: Upgraded the `.iso` property getter from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and conforms to RFC 3339 by gracefully omitting fractional seconds when they evaluate to exactly zero. - -Thank you for continuing to build with Tempo! diff --git a/packages/tempo/doc/releases/index.md b/packages/tempo/doc/releases/index.md index 162dd110..a2ed6ddc 100644 --- a/packages/tempo/doc/releases/index.md +++ b/packages/tempo/doc/releases/index.md @@ -2,7 +2,8 @@ Explore the evolution of Tempo through its version history. -- [Version 3.x (Planned)](./v3.x) - Removal of deprecated shorthands and major engine hardening. +- [Version 3.x](./v3.x) - Removal of deprecated shorthands and major engine hardening. +- [Version 4.x (Planned)](./v4.x) - Removal of deprecated legacy discovery root properties. - [Version 2.x (Current)](./v2.x) - Modular architecture, Shorthand engine, and Ticker stability. - [Version 1.x (Legacy)](./v1.x) - Initial public release and Temporal polyfill integration. - [Version 0.x (Legacy)](./v0.x) - Initial release. diff --git a/packages/tempo/doc/releases/v3.x.md b/packages/tempo/doc/releases/v3.x.md index 962c369a..34eba4cf 100644 --- a/packages/tempo/doc/releases/v3.x.md +++ b/packages/tempo/doc/releases/v3.x.md @@ -1,13 +1,45 @@ # 📜 Version 3.x History -## [v3.0.0] - (Planned) +## [v3.1.0] - 2026-06-13 + +### ✨ What's New +- **Chained Formatting Modifiers**: A powerful new format modifier engine (`{mon:locale:upper}`) allowing dynamic casing (`:upper`, `:lower`), ordinal suffixes (`:ord`), and deep localization (`:locale`) dynamically via the native `Intl` API. +- **Auto-Localization Engine**: Global configurations for `format: { localize: true }` and `parse: { localize: true }` provide a massive leap forward in out-of-the-box internationalization. Tempo can now intelligently parse localized input (months, weekdays, relative terms like 'demain') and automatically format localized output using memoized, high-performance `Intl` strategies. +- **Global `locales` Registry**: Centralized management for augmenting specific term strings per locale globally across instances via `Tempo.init({ locale: 'fr-FR', registry: { locales: { ... } } })`. + +### 🏗️ Internal Refactoring +- **Intl Instantiation**: Upgraded internal architectures to memoize and pool `Intl.DateTimeFormat` objects seamlessly, ensuring parsing localization generation and output formatting impose virtually zero performance hit on hot execution paths. +- **Term Localization**: Upgraded the Term formatting resolution pipeline to support falling back across a strict precedence: Global Registry > Plugin Bundled Dictionary > term's existing label/value. + +## [v3.0.0] - 2026-06-08 + ### 🚨 Major Breaking Changes - **Term Registry Consolidation**: Removed the legacy and deprecated `term` property from the `Discovery` configuration object. All Term-based plugins must now be registered via the `terms` (plural) array. - **Shorthand Configuration Removal**: Removed support for shorthand root-level properties in the `Discovery` object that have been superseded by nested configuration groups: - `relativeTime` shorthand has been removed; use `intl.relativeTime` instead. - `term` shorthand has been removed; use `terms` instead. - **Strict Parsing Mode**: The parser now enforces a stricter `guard` check by default, reducing the likelihood of "false positive" matches on ambiguous strings. +- **Ticker Module Extraction**: To lighten the core bundle, the `TickerModule` has been extracted into its own standalone premium plugin (`@magmacomputing/tempo-plugin-ticker`). It is protected by a License Key via the Tempo Registry. + +### ✨ What's New +- **Formatting Module Additions**: Added new compact date tokens (`{dmy}`, `{mdy}`, `{ymd}`) for generating 8-digit compact date strings (e.g. `24102026`). `{hhmiss}` has been renamed to `{hms}` for consistency. +- **Ordinal Tokens**: Uppercase variants of standard date tokens (`{DAY}`, `{WW}`, `{MM}`) now output their ordinal string representation (e.g., `24th`, `1st`, `2nd`). + +### 📦 Migration Path for `Tempo.ticker()` Users +If you are upgrading from v2.x and your application relies on `Tempo.ticker()`, you will need to update your integration: +1. **Install the Plugin**: `npm install @magmacomputing/tempo-plugin-ticker` +2. **Activate your License**: Visit [registry.magmacomputing.com.au](https://registry.magmacomputing.com.au) to obtain your free JWT license key. +3. **Register the Plugin**: Wire the key into your application and extend Tempo: + ```javascript + import { Tempo } from '@magmacomputing/tempo'; + import { TickerModule } from '@magmacomputing/tempo-plugin-ticker'; + + Tempo.init({ license: 'YOUR_JWT_KEY' }); + Tempo.extend(TickerModule); + ``` ### 🏗️ Internal Refactoring - **Zero-Fallback Initialization**: Cleaned up the `Tempo.init()` bootstrap logic to remove legacy compatibility layers, resulting in a cleaner internal state and reduced bundle size. +- **Build Pipelines**: Fully synchronized build pipelines and TS declarations to ensure `vitest` and `tsc` operate seamlessly across local and premium workspaces. +- **ISO Getter Precision**: Upgraded the `.iso` property getter from native `Date.toISOString()` to Temporal's `Instant.toString()`. This provides full ISO 8601 nanosecond precision and conforms to RFC 3339 by gracefully omitting fractional seconds when they evaluate to exactly zero. diff --git a/packages/tempo/doc/releases/v4.x.md b/packages/tempo/doc/releases/v4.x.md new file mode 100644 index 00000000..8e78ddd7 --- /dev/null +++ b/packages/tempo/doc/releases/v4.x.md @@ -0,0 +1,21 @@ +# 📜 Version 4.x History + +## [v4.0.0] - (Planned) + +### 🚨 Major Breaking Changes +- **Configuration Namespace Enforcement**: Removed all legacy root-level property access that was deprecated during the `v3.x` lifecycle. + - `formats` configuration key has been entirely removed from the `Options` and `Discovery` interfaces. You must use `registry: { formats: ... }` instead. + +### 🗑️ API Removals +- **Removed Static Accessors**: + - `Tempo.formats` has been completely removed. Use `Tempo.registry.formats` instead. + +### 🏗️ Internal Architecture +- **Namespace-Only Configurations**: The internal `Config` state mapping now exclusively enforces nested schema access without mapping wrappers. +- **Registry Consolidation**: The overarching architectural goal for v4.x is to move *all* remaining data dictionaries into the `registry` namespace to fully separate data stores from module settings. The following top-level options are slated to be transitioned under `registry` in a phased approach: + - `event` -> `registry.events` + - `period` -> `registry.periods` + - `snippet` -> `registry.snippets` + - `layout` -> `registry.layouts` + - `timeZones` -> `registry.timeZones` + - `numbers` -> `registry.numbers` diff --git a/packages/tempo/doc/tempo.config.md b/packages/tempo/doc/tempo.config.md index 03ef238c..cb6d1365 100644 --- a/packages/tempo/doc/tempo.config.md +++ b/packages/tempo/doc/tempo.config.md @@ -14,16 +14,6 @@ Settings are loaded in the following order (where later stages override earlier --- -## 🔒 Registry Protection (Soft Freeze) - -- **Read-Only Proxy**: Core registries (`TIMEZONE`, `FORMAT`, etc.) are returned as read-only proxies. Any attempt to directly assign to them will fail. -- **Controlled Extension**: To update a registry, you must use `Tempo.extend()` or `Tempo.init()`. This ensures internal caches (like the Master Guard regex) are synchronized. -- **Atomic Updates**: Multiple extensions are batched, ensuring that the parsing engine is only rebuilt once per change. - -This strategy prevents accidental state corruption while maintaining the flexible, extensible nature of the library. - ---- - ## 🏆 Best Practice: The `tempo.config.ts` Pattern Rather than scattering `Tempo.init()` or `Tempo.extend()` calls throughout your application, the recommended best practice is to centralize your environment setup into a single `tempo.config.ts` (or `.js`) file. @@ -37,7 +27,8 @@ import { CronModule } from '@magmacomputing/tempo-plugin-cron'; import { SLAModule } from '@magmacomputing/tempo-plugin-sla'; export const GlobalTempoConfig = { - timeZone: 'Australia/Sydney', // Set your baseline timezone + timeZone: 'Australia/Sydney', // Set your baseline timezone + license: 'eyJhbGciOiJIUzI1...', // JWT Commercial License for Premium Plugins plugins: [CronModule, SLAModule], // Register enterprise plugins period: { 'market-open': '09:30', @@ -51,6 +42,11 @@ Tempo.init(GlobalTempoConfig); You can then import this file at the very top of your application's entry point (e.g., `main.ts` or `index.js`) to guarantee the configuration is locked in before any other files import `Tempo`. +::: tip +**Looking to configure Internationalization?** +Tempo offers deep integration with native `Intl` APIs for both parsing and formatting foreign languages out-of-the-box. See the [Internationalized Parsing](./tempo.parse.md#internationalized-parsing-locales) and [Auto-Localization Formatting](./tempo.cookbook.md#auto-localization) guides for configuration details. +::: + ```typescript // main.ts import './tempo.config.ts'; @@ -67,12 +63,12 @@ The first layer Tempo checks after its own internal defaults is persistent stora ```javascript // Write a preference to localStorage under the default key ('$Tempo') Tempo.writeStore({ timeZone:'Australia/Sydney' }); -// Write a preference to localStorage under the key 'userSettings' -Tempo.writeStore({ timeZone: 'America/New_York' }, 'userSettings'); +// Write a preference to localStorage under the key 'mySettings' +Tempo.writeStore({ timeZone: 'America/New_York' }, 'mySettings'); -// On the next page load or session, Tempo will use the default store ('$Tempo') automatically -// or to apply a different store on the next page load or session, initialize with that store: -Tempo.init({ store: 'userSettings' }); +// Later, or in another file, initialize Tempo pointing to that key +// It will automatically read 'America/New_York' and apply it +Tempo.init({ store: 'mySettings' }); ``` --- @@ -93,12 +89,12 @@ By default, the key is `Symbol.for('$Tempo')`. ```javascript // Must run before the first Tempo module is evaluated -globalThis[Symbol.for('$Tempo')] = { +globalThis[Symbol.for('$Tempo')] = Object.freeze({ options: { timeZone: 'Europe/Paris' }, timeZones: { MYTZ: 'Asia/Dubai' }, - formats: { myFormat: '{dd}!!{mm}!!{yyyy}' }, + registry: { formats: { myFormat: '{dd}!!{mm}!!{yyyy}' } }, terms: [myCustomTermPlugin] -}; +}); // Load Tempo after the discovery object is in place const { Tempo } = await import('@magmacomputing/tempo'); @@ -117,12 +113,13 @@ import { Tempo } from '@magmacomputing/tempo'; Tempo.extend({ options: { timeZone: 'Europe/Paris' }, timeZones: { MYTZ: 'Asia/Dubai' }, - formats: { myFormat: '{dd}!!{mm}!!{yyyy}' }, + registry: { formats: { myFormat: '{dd}!!{mm}!!{yyyy}' } }, terms: [myCustomTermPlugin] }); ``` ### Security and Ergonomics Notes +- **Tamper Prevention**: When utilizing Global Discovery in a shared environment (like micro-frontends), it is highly recommended to `Object.freeze()` your configuration. Tempo only reads from this object, so freezing it prevents third-party scripts from injecting unauthorized plugins before Tempo boots up. - Global Discovery is convenient for host-controlled bootstraps and cross-bundle handoff. - `Tempo.extend(...)` is usually safer in app code because configuration is explicit, local, and easier to trace. - Use Global Discovery when you must configure `Tempo` before the first `Tempo` import executes. @@ -137,13 +134,9 @@ Tempo looks for the following structure: | `terms` | `TermPlugin \| TermPlugin[]` | Custom Term plugin to be registered. | | `timeZones` | `Record` | Custom timezone aliases to be merged. | | `numbers` | `Record` | Custom number-word aliases merged into the NUMBER registry. | -| `formats` | `Record` | Custom format strings to be merged into `Tempo.FORMAT`. | +| `registry` | `{ formats?: Record, locales?: Record }` | Custom configuration for internal dictionary registries. | | `ignore` | `string \| string[] \| (() => string \| string[])` | Additional noise words to merge into parser ignore rules. | -::: info -Legacy discovery key `term` (singular) is still accepted for backward compatibility, but `terms` is the supported contract key. -::: - --- ## 3. Explicit Initialization (`Tempo.init`) @@ -177,7 +170,8 @@ Tempo.init({ | `period` | `Record` | Built-in aliases | Custom time aliases merged into the period registry. | | `snippet` | `Record` | Built-in snippets | Custom snippet patterns used to compose parse layouts. | | `layout` | `Record` | Built-in layouts | Custom parse layouts for date/time pattern matching. | -| `formats` | `Record` | Built-in formats | Named format aliases merged into `Tempo.FORMAT`. | +| `registry` | `{ formats?, locales? }` | Built-in registries | Internal dictionary mappings (e.g., custom format tokens or localization dictionaries). | +| `format` | `{ localize?: boolean }` | `{ localize: false }` | Formatting behavior preferences, such as enabling auto-localization. | | `plugins` | `Plugin \| Plugin[]` | `[]` | Plugins/modules to extend during initialization. Unlike core init options such as `snippet`, `layout`, `event`, or `period`, these values are not merged into internal state via `extendState`; `Tempo.init()` applies each plugin with `Tempo.extend(p)`, so plugin authors should treat them as instance/class augmentations rather than internal-state merges. | | `store` | `string` | `'$Tempo'` | Persistent storage key used by `readStore`/`writeStore`. | | `discovery` | `string \| symbol` | `'$Tempo'` symbol key | Discovery slot used to resolve global discovery config. | @@ -326,29 +320,3 @@ Tempo.init({ **Hidden Keys**: The `tempo.config` getter excludes internal properties like `anchor` and input-only properties like `value` to keep the public API clean. These properties are still used internally for relative date resolution and instance hydration. ::: ---- - -## 📅 TIMEZONE Registry -Tempo includes a built-in registry of common timezone abbreviations. These are stored in the `TIMEZONE` export. - -| Alias | IANA Identifier | -| :--- | :--- | -| `utc` | `UTC` | -| `gmt` | `Europe/London` | -| `est` | `America/New_York` | -| `cst` | `America/Chicago` | -| `mst` | `America/Denver` | -| `pst` | `America/Los_Angeles` | -| `aest` | `Australia/Sydney` | -| `acst` | `Australia/Adelaide` | -| `awst` | `Australia/Perth` | -| `nzt` | `Pacific/Auckland` | -| `cet` | `Europe/Paris` | -| `eet` | `Europe/Helsinki` | -| `ist` | `Asia/Kolkata` | -| `npt` | `Asia/Kathmandu` | -| `jst` | `Asia/Tokyo` | - -::: tip -You can extend this list or override existing aliases using `Tempo.extend({ timeZones: { ... } })`. -::: diff --git a/packages/tempo/doc/tempo.cookbook.md b/packages/tempo/doc/tempo.cookbook.md index 29b6aa57..c4067648 100644 --- a/packages/tempo/doc/tempo.cookbook.md +++ b/packages/tempo/doc/tempo.cookbook.md @@ -99,6 +99,11 @@ new Tempo('2 weeks ago'); new Tempo('tomorrow afternoon'); ``` +::: tip +**Looking for Internationalized Parsing?** +Tempo can automatically translate months, weekdays, and relative terms (like 'yesterday', 'today', 'tomorrow') into foreign languages using your `locale` configuration. This requires enabling the parser option `parse: { localize: true }` (or the top-level `localize: true` flag) alongside your locale setting. See the [Smart Parsing Guide](./tempo.parse.md#internationalized-parsing-locales) for full documentation and current capabilities. +::: + ### Parsing Unix Timestamps Tempo handles both milliseconds (Number) and nanoseconds (BigInt). ```typescript @@ -218,6 +223,73 @@ const t = new Tempo(); console.log(t.format('We are currently in the {#quarter}')); // "We are currently in the First Quarter" ``` +### Format Modifiers & Localization +Format strings support chained colon-modifiers (`:modifier`) to dynamically change the presentation casing or delegate to the native `Intl` API! + +* `:lower` (Lowercase) +* `:upper` (Uppercase) +* `:title` (Titlecase) +* `:ord` (Ordinal suffix, e.g. "th", "st", "nd") +* `:locale` (Delegates deeply localized tokens like `{mon}` or `{wkd}` directly to `Intl.DateTimeFormat`) + +Modifiers can be stacked endlessly to get the exact presentation required: +```typescript +const t = new Tempo('2024-05-15 15:30', { locale: 'fr-FR' }); + +t.format('{mon:upper}'); // "MAY" (Default English TitleCase -> UpperCase) +t.format('{mon:locale}'); // "mai" (Native French Intl output) +t.format('{mon:locale:upper} {dd:ord}'); // "MAI 15e" +t.format('{#tod:lower}'); // "afternoon" (Modifies the native TitleCase Term plugin) +t.format('{mer:upper}'); // "PM" (Replaces the legacy {MER} token) +``` + +#### Auto-Localization +To avoid repeatedly typing `:locale` on every token, you can set `format: { localize: true }` in your global `Tempo.init()`. This will automatically append the `:locale` modifier (before casing modifiers) for all format evaluations: +```typescript +Tempo.init({ locale: 'fr-FR', format: { localize: true } }); +const t = new Tempo('2024-05-15 15:30'); + +// Automatically localized! +t.format('{mon:upper}'); // "MAI" +t.format('{#tod}'); // "Après-midi" +``` + +#### Global LOCALE Registry +The easiest way to augment or override translations globally is via the `locales` configuration option. Translations added here will apply to *any* plugin that resolves the specified key: +```typescript +Tempo.init({ + locale: 'fr-FR', + registry: { + locales: { + fr: { + morning: 'Matinée', + afternoon: 'Après-midi', + // Supports functions for dynamic resolution! + ordinal: (n) => n === 1 ? '1er' : `${n}e` + } + } + } +}); + +const t = new Tempo('2024-05-15 10:30', { locale: 'fr-FR' }); +console.log(t.format('{#tod:locale}')); // "Matinée" +``` + +#### Term Bundled Dictionary +Plugin authors can optionally bundle a `locale` dictionary directly into their custom Term definition: +```typescript +Tempo.addTerm({ + key: 'shift', + label: 'Shift', + locale: { + es: 'Turno', + de: 'Schicht' + }, + // ... logic +}); +``` +*Note: A user's Global `locales` config will always take precedence over a plugin's bundled dictionary.* + --- ::: info @@ -273,11 +345,11 @@ for await (const t of quarterly) { Automatically update a UI when a daily time period (e.g., 'morning' or 'afternoon') changes. ```typescript -using shiftTicker = Tempo.ticker({ '#period': 1 }, (t) => { - document.body.className = `shift-${t.term.per}`; +using shiftTicker = Tempo.ticker({ '#timeOfDay': 1 }, (t) => { + document.body.className = `shift-${t.term.tod}`; }); -using dailyTicker = Tempo.ticker({ '#period': 'morning' }, (t) => { +using dailyTicker = Tempo.ticker({ '#timeOfDay': 'morning' }, (t) => { document.body.className = `morning-has-broken`; }); ``` @@ -320,3 +392,22 @@ const pdt = new Tempo().toPlainDate(); // Temporal.PlainDate const dates = [new Tempo('tomorrow'), new Tempo('yesterday'), new Tempo('today')]; dates.sort(Tempo.compare); // Sorts chronologically ``` + +### Registry and Formats Configurations +You can extend the built-in registries (e.g. `formats`, `locales`) and toggle formatting preferences using the nested `registry` and `format` properties. + +```typescript +Tempo.init({ + registry: { + formats: { + 'customDate': '{yyyy}-{mm}-{dd} {HH}:{mi}' + } + }, + format: { + localize: true // Enable automatic localized number formatting + } +}); + +const t = new Tempo('2026-06-03 14:30'); +console.log(t.format('customDate')); // "2026-06-03 14:30" +``` diff --git a/packages/tempo/doc/tempo.parse.md b/packages/tempo/doc/tempo.parse.md index c5385447..c67de066 100644 --- a/packages/tempo/doc/tempo.parse.md +++ b/packages/tempo/doc/tempo.parse.md @@ -111,9 +111,9 @@ Tempo.extend(ParseModule); --- -## 🌍 TimeZone & Locale Awareness +## 🌍 Internationalization, TimeZone & Locale -Tempo uses your configuration to resolve ambiguous dates. +Tempo uses your configuration to intelligently parse ambiguous dates and foreign languages. ### 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). @@ -125,18 +125,49 @@ const us = new Tempo('04012026', { timeZone: 'America/New_York' }); // Apr 1 const au = new Tempo('04012026', { timeZone: 'Australia/Sydney' }); // Jan 4 ``` +### Internationalized Parsing (Locales) +Tempo can be instructed to automatically generate language-specific parsing rules based on your `locale`. This enables parsing of translated months, weekdays, and relative events out-of-the-box! + +```typescript +Tempo.init({ locale: 'fr-FR', parse: { localize: true } }); + +// Natively understand French dates and core events! +new Tempo('demain'); // parses as "tomorrow" +new Tempo('15 fevrier 2026'); // parses as "15 February 2026" +new Tempo('vendredi'); // parses as the closest "Friday" +``` + +#### How it Works & Accent Normalization +When `parse: { localize: true }` is enabled, Tempo uses the native `Intl` API to pre-generate lists of Months, Weekdays, and Relative terms ("yesterday", "today", "tomorrow"). + +It also automatically **normalizes and strips accents** from these generated rules. This means that if a user types `fevrier` (without the accent), it will still successfully fuzzy-match against the strictly translated `février`. + +#### ⚠️ Current Limitations (What is NOT Available) +While `Intl` provides a robust foundation for month and weekday translations, there are limits to auto-localization: +* **English Affixes**: Grammatical connector words like "ago", "next", "last", "in", and "from now" are heavily English-biased syntax rules. `Intl` does not provide translations for these parsing connectors. When using the `Tempo` constructor with `parse: { localize: true }`, a relative string like `2 days ago` or `next Friday` will only parse correctly if the English connector keywords (`ago`, `next`) are used, unless Custom Aliases are used to bridge the gap. +* **Time Units**: Words representing time units ("days", "weeks", "months") inside natural language strings are currently English-only. +* **Grammar Structure**: The parser expects sequences matching standard English formats (e.g., `[value] [unit] [affix]`). Highly inflected languages or completely different phrase structures might fail to parse. + +To bridge these gaps, you can register **Custom Aliases** (see below) to map foreign syntax to specific relative offsets manually! + ### Custom Aliases (Events & Periods) -You can teach the parser new words: +You can teach the parser new words or entire foreign phrases to bridge translation gaps: ```typescript Tempo.init({ + locale: 'fr-FR', + parse: { localize: true }, event: { + // Map a full foreign phrase directly to an English-equivalent relative string + 'vendredi prochain': () => 'next Friday', + // Or standard static events 'launch': '2026-12-01', 'party': () => 'next Friday 8pm' } }); -const t = new Tempo('party'); +const t1 = new Tempo('vendredi prochain'); // Parses accurately to next Friday +const t2 = new Tempo('party'); ``` ### 🧠 Functional Alias Context diff --git a/packages/tempo/doc/tempo.registry.md b/packages/tempo/doc/tempo.registry.md new file mode 100644 index 00000000..136a13ef --- /dev/null +++ b/packages/tempo/doc/tempo.registry.md @@ -0,0 +1,76 @@ +# Registries and Dictionaries + +Tempo uses internal dictionaries—called **Registries**—to map string keys to values, functions, or formats. This is how Tempo resolves timezone abbreviations like `EST`, parses custom month layouts, and translates numbers to words. + +By standardizing these data stores under the `registry` namespace, you can deeply customize how Tempo parses, formats, and understands regional values. + +## Accessing Registries + +In v3.1.0+, you can access active registries using the static `Tempo.registry` getter. These objects are **Read-Only Proxies**. + +```javascript +import { Tempo } from '@magmacomputing/tempo'; + +console.log(Tempo.registry.formats); +// { '{iso}': '{yyyy}-{mm}-{dd}T{HH}:{mi}:{ss}', ... } +``` + +::: warning +Because these registries are frozen proxies, attempting to mutate them directly (e.g., `Tempo.registry.formats.custom = '...'`) will throw an error. This guarantees that internal caches and parser guards remain synchronized. +::: + +To add or override values, you must use `Tempo.extend()` or `Tempo.init()`: + +```javascript +Tempo.extend({ + registry: { + formats: { + custom: '{yyyy}!!{mm}!!{dd}' + } + } +}); +``` + +--- + +## 📅 TIMEZONE Registry + +Tempo includes a built-in registry of common timezone abbreviations. This allows users to pass simple strings like `AEST` instead of full IANA time zone identifiers (`Australia/Sydney`). + +| Alias | IANA Identifier | +| :--- | :--- | +| `utc` | `UTC` | +| `gmt` | `Europe/London` | +| `est` | `America/New_York` | +| `cst` | `America/Chicago` | +| `mst` | `America/Denver` | +| `pst` | `America/Los_Angeles` | +| `aest` | `Australia/Sydney` | +| `acst` | `Australia/Adelaide` | +| `awst` | `Australia/Perth` | +| `nzt` | `Pacific/Auckland` | +| `cet` | `Europe/Paris` | +| `eet` | `Europe/Helsinki` | +| `ist` | `Asia/Kolkata` | +| `npt` | `Asia/Kathmandu` | +| `jst` | `Asia/Tokyo` | + +::: tip +You can extend this list or override existing aliases using `Tempo.extend({ timeZones: { ... } })`. (Note: in v4.x, this will be fully migrated to `registry.timeZones`). +::: + +--- + +## Other Internal Registries + +Tempo leverages several other internal data dictionaries to parse and format dates. As of v3.1.0, the `formats` and `locales` dictionaries have been officially moved to the `registry` configuration namespace. + +- **Formats**: Named format aliases used by the `format()` engine. +- **Locales**: Translation dictionaries used by the `:locale` format modifier. +- **Events**: Custom aliases mapped to specific dates. +- **Periods**: Custom aliases mapped to specific times. +- **Snippets**: Reusable Regex patterns mapped to variables for parsing. +- **Layouts**: Composed string patterns mapped to Regex logic for parsing. +- **Numbers**: Word-to-number dictionaries (e.g., `"one" -> 1`). + +*(Note: The overarching architectural goal for v4.0.0 is to consolidate all remaining dictionaries fully into the `registry` namespace to separate data from behavior.)* diff --git a/packages/tempo/package.json b/packages/tempo/package.json index 2699c1a9..962de83e 100644 --- a/packages/tempo/package.json +++ b/packages/tempo/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/tempo", - "version": "3.0.3", + "version": "3.1.0", "engines": { "node": ">=20.0.0" }, @@ -182,7 +182,7 @@ "test:browser": "vitest run -c vitest.browser.config.ts", "test:ci": "cross-env TEMPO_LICENSE_KEY=\"\" TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 vitest run", "test:ci:prefilter": "cross-env TEMPO_LICENSE_KEY=\"\" TZ=America/New_York LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 TEMPO_PREFILTER_CI=true vitest run", - "repl": "tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", + "repl": "cross-env TEMPO_LICENSE_KEY=\"\" tsx --tsconfig ./src/tsconfig.repl.json -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:dist": "tsx -i --import ./bin/temporal-polyfill.ts --import ./bin/repl.ts", "repl:node": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal --import ./bin/repl.ts", "repl:bare": "tsx --tsconfig ./src/tsconfig.repl.json -i --harmony-temporal", @@ -211,7 +211,7 @@ }, "devDependencies": { "@js-temporal/polyfill": "^0.5.1", - "@magmacomputing/library": "3.0.3", + "@magmacomputing/library": "3.1.0", "@rollup/plugin-alias": "^6.0.0", "javascript-obfuscator": "^5.4.3", "magic-string": "^0.30.21", diff --git a/packages/tempo/plan/localized_modifiers.md b/packages/tempo/plan/localized_modifiers.md new file mode 100644 index 00000000..106f52a4 --- /dev/null +++ b/packages/tempo/plan/localized_modifiers.md @@ -0,0 +1,32 @@ +# Localized Mathematical Modifiers + +## Objective +Enable full localization of mathematical modifier terms (e.g., mapping `"prochain"` to `">"` or `"next"`) and gracefully handle grammatical structure variations, such as inverted word ordering (e.g., trailing modifiers like `[weekday] [modifier]` vs. the English default `[modifier] [weekday]`). + +## Architectural Considerations + +### 1. Decoupling Math from Hardcoded English +Currently, the `parseModifier` function in `engine.lexer.ts` uses a strict `switch` statement that evaluates literal English strings (e.g., `case 'next': return 1`). +- **Challenge**: Passing foreign strings like `"prochain"` directly to this switch fails and defaults to `0`. +- **Solution Space**: Introduce a pre-lexing normalization step or a `modifier` registry that maps foreign string literals to standard internal mathematical tokens (like `>`, `<`, `+`, `-`) before they hit the mathematical evaluator. + +### 2. Lexer & Master Guard Layout Flexibility +Tempo’s `Token.wkd` and standard layouts (e.g., `Pattern.WkdTime`) currently expect modifiers in specific positions (often as prefixes, with limited hardcoded suffixes like `next|last` for English). +- **Challenge**: When `parse: { localize: true }` is enabled, the localized snippet overrides completely drop trailing suffix captures. +- **Solution Space**: Update `support.init.ts` and `support.default.ts` to dynamically generate both prefix and suffix capture groups (`` and ``) in the localized regexes, allowing the parser to extract the modifier regardless of which side of the noun it appears. + +### 3. Locale-Specific Grammatical Nuances +Different languages place modifiers in different structural positions depending on the entity. +- **Challenge**: A language might use a suffix for days (e.g., "vendredi prochain") but a prefix for other temporal periods. +- **Solution Space**: Should structural expectations be strictly tied to `Intl` locale codes, or should the engine use a "greedy" approach where it just attempts to extract modifiers from either side of the token without strictly enforcing grammatical correctness? + +### 4. Configuration API Design +How will developers interact with this new capability? +- **Option A**: A brand new top-level configuration registry: `Tempo.init({ modifier: { 'prochain': 'next', 'dernier': 'last' } })`. +- **Option B**: Expanding the existing `event` or `snippet` objects. +- **Option C**: Can we extract these modifier words automatically from `Intl.RelativeTimeFormat`? (Investigate if `Intl` provides sufficient grammatical connector data). + +### 5. Performance Implications +The core speed of Tempo relies heavily on Master Guard (RegEx) optimization and caching. +- **Challenge**: Adding multiple optional prefix and suffix capture branches to core snippets (like `wkd` and `rel`) will increase the complexity and backtracking potential of the Master Guard patterns. +- **Solution Space**: Ensure careful benchmarking when adding dynamic `` groups to localized patterns. diff --git a/packages/tempo/src/engine/engine.alias.ts b/packages/tempo/src/engine/engine.alias.ts index c34f67cf..10987cd9 100644 --- a/packages/tempo/src/engine/engine.alias.ts +++ b/packages/tempo/src/engine/engine.alias.ts @@ -15,7 +15,7 @@ */ import type { Nullable } from '#library/type.library.js'; -import { isDefined, isFunction } from '#library/assertion.library.js'; +import { isDefined, isFunction, isObject } from '#library/assertion.library.js'; import { Match, logError, logWarn } from '#tempo/support'; import { ownEntries } from '#library/primitive.library.js'; import * as t from '../tempo.type.js'; @@ -84,9 +84,9 @@ export class AliasEngine { if (parent instanceof AliasEngine) { this.#parent = parent; - this.#depth = parent.#depth + 1; - this.#state = Object.create(parent.#state); // create a new state object that inherits from the parent engine's state - this.#words = Object.create(parent.#words); // create a new words object that inherits from the parent engine's words for collision detection + this.#depth = parent.depth + 1; + this.#state = Object.create((parent as any).#state); // create a new state object that inherits from the parent engine's state + this.#words = Object.create((parent as any).#words); // create a new words object that inherits from the parent engine's words for collision detection } else { if (parent) logError("Parent engine must be an instance of AliasEngine", this.#config); @@ -100,6 +100,10 @@ export class AliasEngine { this.#count = { evt: 0, per: 0 }; } + fork(config?: Nullable): AliasEngine { + return new AliasEngine({ parent: this, config: config ?? this.#config ?? null }); + } + /** * Register aliases and return a regex string representing the full lineage of aliases up the proto chain. * Ensures that shadowed/collided baseNames are excluded from parent levels. diff --git a/packages/tempo/src/engine/engine.normalizer.ts b/packages/tempo/src/engine/engine.normalizer.ts index 0b8b8143..4db7fd52 100644 --- a/packages/tempo/src/engine/engine.normalizer.ts +++ b/packages/tempo/src/engine/engine.normalizer.ts @@ -92,6 +92,15 @@ export function normalizeMatch( if (state.errored) return dateTime; // 3. Weekday, Date + if (isDefined(groups["wkd"]) && !isNumeric(groups["wkd"])) { + const rawWkd = String(groups["wkd"]).replace(/\.$/, '').toLowerCase(); + const mappedWkd = state.parse.localeMap?.[rawWkd]; + if (isDefined(mappedWkd)) { + const engWkd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][mappedWkd - 1]; + groups["wkd"] = engWkd; + logDebug(`[Normalizer] Normalized localized weekday string '${rawWkd}' to '${engWkd}'`, state.config); + } + } dateTime = parseWeekday(groups, dateTime, state.config); dateTime = parseDate(groups, dateTime, state.config, state.parse["pivot"]); @@ -224,12 +233,20 @@ export function resolveAliases( } if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { - const mm = prefix(groups["mm"] as t.MONTH); - const monthVal = enums.MONTH[mm]; - - if (isDefined(monthVal)) { - groups["mm"] = monthVal.toString().padStart(2, '0'); - logDebug(`[Normalizer] Normalized month string '${mm}' to ${groups["mm"]}`, state.config); + const rawMm = String(groups["mm"]).replace(/\.$/, '').toLowerCase(); + const mappedMm = state.parse.localeMap?.[rawMm]; + + if (isDefined(mappedMm)) { + groups["mm"] = mappedMm.toString().padStart(2, '0'); + logDebug(`[Normalizer] Normalized localized month string '${groups["mm"]}'`, state.config); + } else { + const mm = prefix(groups["mm"] as t.MONTH); + const monthVal = enums.MONTH[mm]; + + if (isDefined(monthVal)) { + groups["mm"] = monthVal.toString().padStart(2, '0'); + logDebug(`[Normalizer] Normalized month string '${mm}' to ${groups["mm"]}`, state.config); + } } } diff --git a/packages/tempo/src/module/module.format.ts b/packages/tempo/src/module/module.format.ts index 926e7493..a2130bae 100644 --- a/packages/tempo/src/module/module.format.ts +++ b/packages/tempo/src/module/module.format.ts @@ -1,8 +1,9 @@ import '#library/temporal.polyfill.js'; -import { pad } from '#library/string.library.js'; +import { pad, toTitleCase } from '#library/string.library.js'; import { suffix } from '#library/number.library.js'; import { ifNumeric } from '#library/coercion.library.js'; -import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined } from '#library/assertion.library.js'; +import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction } from '#library/assertion.library.js'; +import { formatDayPeriod } from '#library/international.library.js'; import { delegator } from '#library/proxy.library.js'; import { isTempo, enums, Match, getRuntime, NumericPattern } from '#tempo/support'; @@ -34,7 +35,7 @@ export function format(obj: Temporal.ZonedDateTime | any, fmt: string | symbol): export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol): string | number | any { const state = getRuntime().state; const config = isTempo(obj) ? obj.config : state?.config; - const formats = Object.assign({}, enums.FORMAT, config?.formats); + const formats = Object.assign({}, enums.FORMAT, config?.registry?.formats); const tz = config?.timeZone ?? 'UTC'; let zdt: any; @@ -49,7 +50,11 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol zdt = (obj as any).toZonedDateTimeISO(tz); break; case isString(obj): - zdt = (obj as any).includes('[') ? Temporal.ZonedDateTime.from(obj as any) : ((obj as any).includes('T') ? Temporal.PlainDateTime.from(obj as any).toZonedDateTime(tz) : Temporal.PlainDate.from(obj as any).toZonedDateTime(tz)); + zdt = (obj as any).includes('[') + ? Temporal.ZonedDateTime.from(obj as any) + : ((obj as any).includes('T') + ? Temporal.PlainDateTime.from(obj as any).toZonedDateTime(tz) + : Temporal.PlainDate.from(obj as any).toZonedDateTime(tz)); break; case isPlainDateTime(obj): case isPlainDate(obj): @@ -72,7 +77,7 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol : String(fmt); // auto-meridiem: if {HH} is present and {mer} is absent, append it after the last time component - if (template.includes('{HH}') && !template.includes('{mer}') && !template.includes('{MER}')) { + if (template.includes('{HH}') && !template.toLowerCase().includes('{mer')) { const index = Math.max(template.lastIndexOf('{HH}'), template.lastIndexOf('{mi}'), template.lastIndexOf('{ss}')); if (index !== -1) { const end = template.indexOf('}', index) + 1; @@ -80,55 +85,143 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol } } - const result = template.replace(new RegExp(Match.braces, 'g'), (_match: string, token: string) => { + const result = template.replace(new RegExp(Match.formatBraces, 'g'), (_match: string, fullToken: string) => { + const [token, ...modifiers] = fullToken.split(':'); + + if (config?.format?.localize && !modifiers.includes('locale')) + modifiers.unshift('locale'); + + let res: any; + switch (token) { - case 'yyyy': return pad(zdt.year, 4); - case 'yy': return pad(zdt.year % 100); - case 'yw': return pad(zdt.yearOfWeek, 4); - case 'yyww': return pad(zdt.yearOfWeek, 4) + pad(zdt.weekOfYear); - case 'mm': return pad(zdt.month); - case 'mon': return enums.MONTHS.keyOf(zdt.month as any); - case 'mmm': return enums.MONTH.keyOf(zdt.month as any); - case 'dd': return pad(zdt.day); - case 'day': return zdt.day.toString(); - case 'dow': return zdt.dayOfWeek.toString(); - case 'wkd': return enums.WEEKDAYS.keyOf(zdt.dayOfWeek as any); - case 'www': return enums.WEEKDAY.keyOf(zdt.dayOfWeek as any); - case 'ww': return pad(zdt.weekOfYear); - case 'DAY': return suffix(zdt.day); - case 'WW': return suffix(zdt.weekOfYear); - case 'MM': return suffix(zdt.month); - case 'hh': return pad(zdt.hour); - case 'HH': return pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); - case 'mer': return zdt.hour >= 12 ? 'pm' : 'am'; - case 'MER': return zdt.hour >= 12 ? 'PM' : 'AM'; - case 'mi': return pad(zdt.minute); - case 'ss': return pad(zdt.second); - case 'ms': return pad(zdt.millisecond, 3); - case 'us': return pad(zdt.microsecond, 3); - case 'ns': return pad(zdt.nanosecond, 3); - case 'ff': return `${pad(zdt.millisecond, 3)}${pad(zdt.microsecond, 3)}${pad(zdt.nanosecond, 3)}`; - case 'dmy': return `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year, 4)}`; - case 'mdy': return `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year, 4)}`; - case 'ymd': return `${pad(zdt.year, 4)}${pad(zdt.month)}${pad(zdt.day)}`; - case 'hms': return `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; - case 'ts': return ((config?.timeStamp ?? 'ms') === 'ss') + case 'yyyy': res = pad(zdt.year, 4); break; + case 'yy': res = pad(zdt.year % 100); break; + case 'yw': res = pad(zdt.yearOfWeek, 4); break; + case 'yyww': res = pad(zdt.yearOfWeek, 4) + pad(zdt.weekOfYear); break; + case 'mm': res = pad(zdt.month); break; + case 'mon': res = enums.MONTHS.keyOf(zdt.month as any); break; + case 'mmm': res = enums.MONTH.keyOf(zdt.month as any); break; + case 'dd': res = pad(zdt.day); break; + case 'day': res = zdt.day.toString(); break; + case 'dow': res = zdt.dayOfWeek.toString(); break; + case 'wkd': res = enums.WEEKDAYS.keyOf(zdt.dayOfWeek as any); break; + case 'www': res = enums.WEEKDAY.keyOf(zdt.dayOfWeek as any); break; + case 'ww': res = pad(zdt.weekOfYear); break; + case 'DAY': res = suffix(zdt.day); break; + case 'WW': res = suffix(zdt.weekOfYear); break; + case 'MM': res = suffix(zdt.month); break; + case 'hh': res = pad(zdt.hour); break; + case 'HH': res = pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); break; + case 'mer': res = zdt.hour >= 12 ? 'pm' : 'am'; break; + case 'MER': res = zdt.hour >= 12 ? 'PM' : 'AM'; break; + case 'mi': res = pad(zdt.minute); break; + case 'ss': res = pad(zdt.second); break; + case 'ms': res = pad(zdt.millisecond, 3); break; + case 'us': res = pad(zdt.microsecond, 3); break; + case 'ns': res = pad(zdt.nanosecond, 3); break; + case 'ff': res = `${pad(zdt.millisecond, 3)}${pad(zdt.microsecond, 3)}${pad(zdt.nanosecond, 3)}`; break; + case 'dmy': res = `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year, 4)}`; break; + case 'mdy': res = `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year, 4)}`; break; + case 'ymd': res = `${pad(zdt.year, 4)}${pad(zdt.month)}${pad(zdt.day)}`; break; + case 'hms': res = `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; break; + case 'ts': res = ((config?.timeStamp ?? 'ms') === 'ss') ? Math.trunc(zdt.epochMilliseconds / 1000).toString() - : zdt.epochMilliseconds.toString(); - case 'nano': return zdt.epochNanoseconds.toString(); - case 'tz': return zdt.timeZoneId; + : zdt.epochMilliseconds.toString(); break; + case 'nano': res = zdt.epochNanoseconds.toString(); break; + case 'tz': res = zdt.timeZoneId; break; default: { if (token.startsWith('#') && isTempo(obj)) { - const res = (obj as unknown as Tempo).term[token.slice(1)]; - if (isObject(res)) return res.label ?? res.key ?? `{${token}}`; - return res ?? `{${token}}`; + const termObj = (obj as unknown as Tempo).term[token.slice(1)]; + if (isObject(termObj)) { + res = termObj.label ?? termObj.key ?? `{${token}}`; + } else { + res = termObj ?? `{${token}}`; + } + } else { + res = `{${token}}`; } - return `{${token}}`; + break; } } + + if (res === `{${token}}` || modifiers.length === 0) return res; + + for (const mod of modifiers) { + switch (mod.toLowerCase()) { + case 'lower': + res = String(res).toLocaleLowerCase(config?.locale); + break; + case 'upper': + res = String(res).toLocaleUpperCase(config?.locale); + break; + case 'title': + res = toTitleCase(String(res), config?.locale); + break; + case 'ord': + res = suffix(parseInt(String(res), 10)); + break; + case 'locale': { + try { + if (token.startsWith('#') && isTempo(obj)) { + const termKey = token.slice(1); + const termName = termKey.split('.')[0]; + const plugin = (obj.constructor as any)._termMap?.get(termName); + + if (plugin) { + const termVal = (obj as unknown as Tempo).term[termKey]; + const lang = config?.locale?.split('-')[0] ?? 'en'; + let locRes: any; + let valStr: string; + let baseKey: string | undefined; + + if (isObject(termVal)) { + valStr = String(termVal.label ?? termVal.key ?? termVal.id); + baseKey = String(termVal.key ?? termVal.id); + } else { + valStr = String(termVal); + } + + // 1. Global Registry (user override) + if (config?.registry?.locales?.[lang]?.[valStr]) + locRes = config.registry.locales[lang][valStr]; + + // 2. Term's Bundled Dictionary (plugin default) + else { + const searchKey = baseKey ?? valStr; + const flatGroups = Array.isArray(plugin.groups) ? plugin.groups : (isObject(plugin.groups) ? Object.values(plugin.groups).flat() : []); + const group = flatGroups.find((g: any) => g.key === searchKey); + if (group && isObject(group.locale)) + locRes = group.locale[lang] ?? group.locale.en; + + } + + // 3. Execution or Assignment + if (isDefined(locRes)) + res = isFunction(locRes) ? locRes(config?.locale) : locRes; + } + } else { + const dtOptions = config?.intl?.dateTimeFormat ?? {}; + if (token === 'mon') res = zdt.toLocaleString(config?.locale, { ...dtOptions, month: 'long' }); + else if (token === 'mmm') res = zdt.toLocaleString(config?.locale, { ...dtOptions, month: 'short' }); + else if (token === 'wkd') res = zdt.toLocaleString(config?.locale, { ...dtOptions, weekday: 'long' }); + else if (token === 'www') res = zdt.toLocaleString(config?.locale, { ...dtOptions, weekday: 'short' }); + else if (token === 'mer') { + const period = formatDayPeriod(zdt.epochMilliseconds, config?.locale, { ...dtOptions, hour: 'numeric', hour12: true, timeZone: tz }); + if (period) res = period; + } + } + } catch (e) { + // Fallback to the current base token string + } + break; + } + } + } + + return res; }); - const tokens = template.match(new RegExp(Match.braces, 'g')); + const tokens = template.match(new RegExp(Match.formatBraces, 'g')); const isNumericOutput = (NumericPattern as readonly string[]).includes(template as any) || (tokens && tokens.length > 1 && /^[0-9]+$/.test(result)); return (isNumericOutput ? ifNumeric(result, true) : result) as any; } diff --git a/packages/tempo/src/module/module.parse.ts b/packages/tempo/src/module/module.parse.ts index 71a11415..4099aab3 100644 --- a/packages/tempo/src/module/module.parse.ts +++ b/packages/tempo/src/module/module.parse.ts @@ -17,8 +17,7 @@ import { getRange, getTermRange } from '../plugin/term/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/term/term.type.js'; -import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods } from '../support/support.index.js'; -import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; +import { sym, isTempo, TermError, getRuntime, Match, TempoError, $setEvents, $setPeriods, markConfig, setPatterns, init, extendState } from '#tempo/support'; import { setProperty, logError, logDebug } from '#tempo/support/support.util.js'; import * as t from '../tempo.type.js'; @@ -62,7 +61,23 @@ const _ParseEngine = { const val = dateTime ?? state.anchor ?? state.config.anchor ?? (isTempo(tempo) ? (tempo as any).toDateTime() : (isZonedDateTime(tempo) ? tempo : (isInstant(tempo) ? tempo.toZonedDateTimeISO(config.timeZone) : undefined))); const [tz, cal] = getTemporalIds(config.timeZone, config.calendar); - const basis = isTempo(val) ? (val as any).toDateTime() : (isDefined(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal)); + + let basis: Temporal.ZonedDateTime; + if (isTempo(val)) basis = (val as any).toDateTime(); + else if (isZonedDateTime(val)) basis = val; + else if (isDefined(val)) { + const safeConfig = { ...state.config }; + delete safeConfig.anchor; + if (TempoClass) { + basis = (TempoClass as any).from(val, safeConfig).toDateTime(); + } else { + const ms = val instanceof Date ? val.getTime() : (typeof val === 'number' || typeof val === 'bigint' ? Number(val) : new Date(String(val)).getTime()); + basis = Temporal.Instant.fromEpochMilliseconds(ms || Date.now()).toZonedDateTimeISO(tz).withCalendar(cal); + } + } else { + basis = instant().toZonedDateTimeISO(tz).withCalendar(cal); + } + const isAnchored = isDefined(val); if (isRoot) { state.parse.anchor = basis; @@ -154,7 +169,6 @@ const _ParseEngine = { const TempoClass = getRuntime().modules['Tempo']; const terms = state.pluginsDb.terms; - if (isTempo(dateTime)) dateTime = dateTime.toDateTime(); if (!isZonedDateTime(dateTime)) { logError(new TypeError(`Sacred Anchor corrupted: ${String(value)}`), state.config); @@ -207,7 +221,12 @@ const _ParseEngine = { trim = trim.replace(pat, ' ').replace(Match.spaces, ' ').trim(); } - const guard = (TempoClass as any)?.[sym.$guard]?.test(trim) ?? true; + let guard = (TempoClass as any)?.[sym.$guard]?.test(trim) ?? true; + + // 🛡️ Bypass the strict global guard if the current instance is using localized parsing + if (!guard && state.parse.localize) { + guard = true; + } if (!guard) { const keys = (obj: any) => { @@ -322,6 +341,8 @@ const _ParseEngine = { for (const [symKey, pat] of orderedPatterns) { const groups = _ParseEngine.parseMatch(state, pat, trim); + + if (isEmpty(groups)) continue; @@ -344,6 +365,8 @@ const _ParseEngine = { if (!isAnchored && !hasTime && !isChanged) dateTime = dateTime.withPlainTime('00:00:00'); + + if (isZonedDateTime(dateTime)) Object.assign(arg, { type: 'Temporal.ZonedDateTime', value: dateTime, match: symKey.description, groups }); diff --git a/packages/tempo/src/plugin/term/term.timeline.ts b/packages/tempo/src/plugin/term/term.timeline.ts index 9d961670..77e55b07 100644 --- a/packages/tempo/src/plugin/term/term.timeline.ts +++ b/packages/tempo/src/plugin/term/term.timeline.ts @@ -3,14 +3,14 @@ import type { Tempo } from '../../tempo.class.js'; /** definition of daily time periods */ const groups = defineRange([ - { key: 'Midnight', hour: 0, group: 'standard' }, - { key: 'Early', hour: 4, group: 'standard' }, - { key: 'Morning', hour: 8, group: 'standard' }, - { key: 'Midmorning', hour: 10, group: 'standard' }, - { key: 'Midday', hour: 12, group: 'standard' }, - { key: 'Afternoon', hour: 15, minute: 30, group: 'standard' }, - { key: 'Evening', hour: 18, group: 'standard' }, - { key: 'Night', hour: 20, group: 'standard' }, + { key: 'Midnight', locale: { fr: 'Minuit', es: 'Medianoche', de: 'Mitternacht' }, hour: 0, group: 'standard' }, + { key: 'Early', locale: { fr: 'Tôt', es: 'Temprano', de: 'Früh' }, hour: 4, group: 'standard' }, + { key: 'Morning', locale: { fr: 'Matin', es: 'Mañana', de: 'Morgen' }, hour: 8, group: 'standard' }, + { key: 'Midmorning', locale: { fr: 'Milieu de la matinée', es: 'Media mañana', de: 'Vormittag' }, hour: 10, group: 'standard' }, + { key: 'Midday', locale: { fr: 'Midi', es: 'Mediodía', de: 'Mittag' }, hour: 12, group: 'standard' }, + { key: 'Afternoon', locale: { fr: 'Après-midi', es: 'Tarde', de: 'Nachmittag' }, hour: 15, minute: 30, group: 'standard' }, + { key: 'Evening', locale: { fr: 'Soir', es: 'Noche', de: 'Abend' }, hour: 18, group: 'standard' }, + { key: 'Night', locale: { fr: 'Nuit', es: 'Noche', de: 'Nacht' }, hour: 20, group: 'standard' }, ], 'group'); function resolve(t: Tempo, anchor?: any) { diff --git a/packages/tempo/src/plugin/term/term.type.ts b/packages/tempo/src/plugin/term/term.type.ts index 494563b3..2f552493 100644 --- a/packages/tempo/src/plugin/term/term.type.ts +++ b/packages/tempo/src/plugin/term/term.type.ts @@ -19,6 +19,7 @@ export interface TermPlugin { key: string; scope?: string; description?: string; + locale?: Record; groups?: any; ranges?: any[]; resolve?: (this: Tempo, anchor?: any) => Range[]; @@ -76,6 +77,7 @@ export type ResolvedRange = Range & { end: Tempo; scope?: string; label?: string; + locale?: Record; unit?: string; rollover?: string; [str: string]: any; diff --git a/packages/tempo/src/support/support.default.ts b/packages/tempo/src/support/support.default.ts index ead903ae..dc648da4 100644 --- a/packages/tempo/src/support/support.default.ts +++ b/packages/tempo/src/support/support.default.ts @@ -16,6 +16,7 @@ const bracket_content = /[^\]]+/; /** @internal Tempo Match patterns */ export const Match = proxify({ /** match all {} pairs, if they start with a word char */ braces: /{([#]?[\w]+(?:\.[\w]+)*)}/g, + /** match {} pairs for formatting, allowing optional chained :modifiers */ formatBraces: /{([#]?[\w]+(?:\.[\w]+)*(?:\:[a-zA-Z]+)*)}/g, /** named capture-group, if it starts with a letter */ captures: /\(\?<([a-zA-Z][\w]*)>(.*?)(?>, } as const; /** @internal Centralized mutable state for all extendable registries */ @@ -125,6 +126,7 @@ export const STATE = { FORMAT: allDescriptors(DEFAULTS.FORMAT), LIMIT: allDescriptors(DEFAULTS.LIMIT), MONTH_DAY: allDescriptors(DEFAULTS.MONTH_DAY), + LOCALE: allDescriptors(DEFAULTS.LOCALE), } const defineExtensible = (target: any) => Object.defineProperty(target, sym.$Extensible, { value: true, enumerable: false, configurable: false, writable: false }); @@ -134,6 +136,7 @@ defineExtensible(STATE.TIMEZONE); defineExtensible(STATE.DURATION); defineExtensible(STATE.DURATIONS); defineExtensible(STATE.MONTH_DAY); +defineExtensible(STATE.LOCALE); /** Gregorian calendar week-days (short-form) */ export const WEEKDAY = enumify(['All', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); @@ -203,6 +206,9 @@ export const LIMIT = proxify(STATE.LIMIT, true, false); /** regional month-day-year parsing settings */ export const MONTH_DAY = proxify(STATE.MONTH_DAY, true, false); +/** localized dictionary translations */ +export const LOCALE = proxify(STATE.LOCALE, true, true); + /** date-time element tokens */ const elementKeys = ['yy', 'mm', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; export const ELEMENT = enumify({ @@ -233,7 +239,7 @@ export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf /** allowed keys for Tempo configuration options */ -const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'formats', 'plugins'] as const; +const configKeys = ['config', 'parse', 'value', 'intl', 'store', 'discovery', 'debug', 'catch', 'timeZone', 'calendar', 'locale', 'sphere', 'timeStamp', 'formats', 'plugins', 'locales'] as const; export const CONFIG = enumify(configKeys, false); export type Config = KeyOf @@ -266,7 +272,7 @@ export type LICENSE = ValueOf /** @internal LIVE Registries mapping (STATE key -> Enum/Proxy) */ export const REGISTRIES: Record = { - NUMBER, DURATION, TIMEZONE, DURATIONS, FORMAT, LIMIT, MONTH_DAY, + NUMBER, DURATION, TIMEZONE, DURATIONS, FORMAT, LIMIT, MONTH_DAY, LOCALE } /** public-reachable enums */ @@ -291,4 +297,5 @@ export default { PARSE, MONTH_DAY, LICENSE, + LOCALE, } diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 011ca2e1..52fe852f 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -31,7 +31,7 @@ export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './support.symbol.js'; export { $Tempo, $Register, $Interpreter, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $setAliases, $buildGuard, $IsBase, $Identity, $LogConfig, $Discover, $ImmutableSkip } from './support.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './support.register.js'; -export { getRuntime, TempoRuntime } from './support.runtime.js'; +export { getRuntime, resetRuntime, TempoRuntime } from './support.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './support.default.js'; export { SCHEMA, getLargestUnit, logError, logWarn, logDebug, logTrace, setLogLevel, logTempo } from './support.util.js'; export { setPatterns } from '../engine/engine.pattern.js'; diff --git a/packages/tempo/src/support/support.init.ts b/packages/tempo/src/support/support.init.ts index 03393aca..07f8b83f 100644 --- a/packages/tempo/src/support/support.init.ts +++ b/packages/tempo/src/support/support.init.ts @@ -1,26 +1,25 @@ 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 { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; import { normalizeUtcOffset } from '#library/temporal.library.js'; import { markConfig } from '#library/symbol.library.js'; import { asType } from '#library/type.library.js'; -import { isString, isObject, isUndefined, isDefined, isRegExp } from '#library/assertion.library.js'; +import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty } from '#library/assertion.library.js'; import { ScopedSet } from '#library/scopedset.class.js'; import { ownEntries } from '#library/primitive.library.js'; import { getStorage } from '#library/storage.library.js'; import { parseLogLevel } from '#library/logger.class.js'; import { getRuntime } from './support.runtime.js'; -import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError } from './support.util.js'; +import { setProperty, setProperties, hasOwn, create, collect, normalizeLayoutOrder, resolveMonthDay, logError, generateLocalizedSnippets } from './support.util.js'; import { setLicense } from '../plugin/license/license.manager.js'; import { sym, Token } from './support.symbol.js'; import { Match, Snippet, Layout, Event, Period, Ignore, Default } from './support.default.js'; -import { STATE, LICENSE } from './support.enum.js'; +import { STATE } from './support.enum.js'; import enums from './support.enum.js'; import * as t from '../tempo.type.js'; -import type { Internal } from '../tempo.type.js'; /** @internal Initialise a Tempo state */ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State): t.Internal.State { @@ -37,6 +36,10 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int }) as t.Internal.State; if (baseState) { + state.config = Object.create(baseState.config); + if (baseState.config.registry) state.config.registry = Object.create(baseState.config.registry); + if (baseState.config.format) state.config.format = Object.create(baseState.config.format); + state.parse = Object.create(baseState.parse); state.userProvidedKeys = new Set(baseState.userProvidedKeys); state.installed = new ScopedSet(runtime.installed); // sandbox: delegates has() to global, isolates add() state.pluginsDb = { @@ -104,6 +107,9 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int Object.defineProperty(state.config, 'get', { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }); } else if (baseState) { state.config = markConfig(Object.create(baseState.config)); + if (baseState.config.registry) state.config.registry = Object.create(baseState.config.registry); + if (baseState.config.format) state.config.format = Object.create(baseState.config.format); + state.parse = Object.create(baseState.parse); setProperties(state.config, { scope: 'local', catch: options.catch ?? (baseState.config as any).catch ?? false, @@ -146,9 +152,25 @@ export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Int } /** @internal Extend a Tempo state with new options (Shadowing) */ -export function extendState(state: t.Internal.State, options: t.Options) { +export function extendState(state: t.Internal.State, options: t.Options): boolean { let patternsDirty = false; + const clearLocalization = () => { + if (state.parse.localeMap) { + delete state.parse.localeMap; + state.parse.snippet[Token.mm as any] = Snippet[Token.mm as any]; + state.parse.snippet[Token.wkd as any] = Snippet[Token.wkd as any]; + + if ((state.parse as any).localizedEvents) { + (state.parse as any).localizedEvents.forEach((k: string) => { + delete state.parse.event[k]; + }); + delete (state.parse as any).localizedEvents; + } + patternsDirty = true; + } + } + ownEntries(options).forEach(([optKey, optVal]) => { if (isUndefined(optVal)) return; @@ -207,19 +229,69 @@ export function extendState(state: t.Internal.State, options: t.Options) { setProperty(state.config, 'calendar', String(arg.value)); break; - case 'locale': - setProperty(state.config, 'locale', String(arg.value)); + case 'locale': { + const resolvedLocale = canonicalLocale(String(arg.value)); + if (resolvedLocale) { + setProperty(state.config, 'locale', resolvedLocale); + if (resolvedLocale.split('-')[0] === 'en') clearLocalization(); + } + break; + } + + case 'format': + if (isObject(arg.value)) state.config.format = { ...(state.config.format || {}), ...arg.value }; + break; + + case 'parse': + if (isObject(arg.value)) { + Object.assign(state.parse, arg.value); + if (!isObject(state.config.parse)) setProperty(state.config, 'parse', {}); + Object.assign(state.config.parse, arg.value); + } + break; + + case 'localize': + if (!isObject(state.config.format)) setProperty(state.config, 'format', {}); + state.config.format!.localize = Boolean(arg.value); + if (!isObject(state.config.parse)) setProperty(state.config, 'parse', {}); + state.config.parse!.localize = Boolean(arg.value); + state.parse.localize = Boolean(arg.value); + if (!state.parse.localize) clearLocalization(); break; case 'discovery': setProperty(state.config, 'discovery', arg.value); break; + case 'registry': + if (isObject(arg.value)) { + if (!state.config.registry) state.config.registry = {} as any; + if (arg.value.formats) { + if (state.config.registry.formats?.extend) state.config.registry.formats = state.config.registry.formats.extend(arg.value.formats) as t.FormatRegistry; + else setProperty(state.config.registry, 'formats', arg.value.formats); + } + if (arg.value.locales) { + if ((state.config.registry.locales as any)?.extend) state.config.registry.locales = (state.config.registry.locales as any).extend(arg.value.locales); + else setProperty(state.config.registry, 'locales', arg.value.locales); + } + } + break; + case 'formats': - if (state.config.formats?.extend) { - state.config.formats = state.config.formats.extend(arg.value) as t.FormatRegistry; + if (!state.config.registry) state.config.registry = {} as any; + if (state.config.registry.formats?.extend) { + state.config.registry.formats = state.config.registry.formats.extend(arg.value) as t.FormatRegistry; } else { - setProperty(state.config, 'formats', arg.value); + setProperty(state.config.registry, 'formats', arg.value); + } + break; + + case 'locales': + if (!state.config.registry) state.config.registry = {} as any; + if ((state.config.registry.locales as any)?.extend) { + state.config.registry.locales = (state.config.registry.locales as any).extend(arg.value); + } else { + setProperty(state.config.registry, 'locales', arg.value); } break; @@ -287,4 +359,37 @@ export function extendState(state: t.Internal.State, options: t.Options) { } }); + + const locale = state.config.locale; + if (locale && state.parse.localize) { + const lang = locale.split('-')[0]; + if (lang !== 'en') { + const { snippets, localeMap, events } = generateLocalizedSnippets(locale); + state.parse.localeMap = localeMap; + Object.assign(state.parse.snippet, snippets); + + // Map to exact lexer Tokens to override default layout placeholders + state.parse.snippet[Token.mm as any] = new RegExp(`(?[0 ]?[1-9]|1[0-2]|${snippets.mmm})`, 'i'); + state.parse.snippet[Token.wkd as any] = new RegExp(`(?${snippets.www})`, 'i'); + + if (!isEmpty(events)) { + Object.assign(state.parse.event, events); + (state.parse as any).localizedEvents = Object.keys(events); + + // Register new localized aliases with the AliasEngine + if (state.aliasEngine) { + // Ensure we don't corrupt global state if we are a local instance + if (state.config.scope === 'local' && state.aliasEngine.depth === 0) { + if (typeof state.aliasEngine.fork === 'function') { + state.aliasEngine = state.aliasEngine.fork(state.config); + } + } + state.aliasEngine.registerAliases('evt', ownEntries(events)); + } + } + patternsDirty = true; + } + } + + return patternsDirty; } diff --git a/packages/tempo/src/support/support.util.ts b/packages/tempo/src/support/support.util.ts index a1704631..822c362e 100644 --- a/packages/tempo/src/support/support.util.ts +++ b/packages/tempo/src/support/support.util.ts @@ -7,6 +7,8 @@ import { asType, getType } from '#library/type.library.js'; import { asArray, asError } from '#library/coercion.library.js'; import { isSymbol, isUndefined, isDefined, isString, isNullish, isObject } from '#library/assertion.library.js'; import { ownEntries, unwrap } from '#library/primitive.library.js'; +import { memoizeFunction } from '#library/function.library.js'; +import { getDTF } from '#library/international.library.js'; import { getRuntime } from './support.runtime.js'; import { LICENSE } from './support.enum.js'; import type * as t from '../tempo.type.js'; @@ -228,3 +230,99 @@ export function resolveDisplayStatus(status: string): string { : String(status) as LICENSE return LICENSE.values().includes(raw) ? raw : LICENSE.Unknown; } + +/** @internal generate localized snippets for months, weekdays, and relative events */ +export const generateLocalizedSnippets = memoizeFunction((locale: string) => { + const map: Record = {}; + const mon: string[] = []; + const mmm: string[] = []; + const wkd: string[] = []; + const www: string[] = []; + const events: Record = {}; + + const dtOptions: Intl.DateTimeFormatOptions = { timeZone: 'UTC' }; + const monthLongFormat = getDTF(locale, { ...dtOptions, month: 'long' }); + const monthShortFormat = getDTF(locale, { ...dtOptions, month: 'short' }); + + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const optionalPunctuation = (s: string) => s.replace(/\\?\.$/, '\\.?'); + const normalizeKey = (s: string) => s.replace(/\.$/, '').toLowerCase(); + const removeAccents = (s: string) => s.normalize("NFD").replace(/[\u0300-\u036f]/g, ""); + + const addEntry = (str: string, index: number, longList: string[], shortList?: string[]) => { + const key = normalizeKey(str); + const unaccented = removeAccents(key); + + map[key] = index; + if (unaccented !== key) map[unaccented] = index; + + longList.push(optionalPunctuation(escapeRegex(str))); + if (unaccented !== key) { + longList.push(optionalPunctuation(escapeRegex(unaccented))); + } + if (shortList) { + shortList.push(optionalPunctuation(escapeRegex(str))); + if (unaccented !== key) { + shortList.push(optionalPunctuation(escapeRegex(unaccented))); + } + } + }; + + for (let m = 0; m < 12; m++) { + const date = new Date(Date.UTC(2024, m, 15)); + const longStr = monthLongFormat.format(date).toLowerCase(); + const shortStr = monthShortFormat.format(date).toLowerCase(); + + addEntry(longStr, m + 1, mon, mmm); + if (shortStr !== longStr) addEntry(shortStr, m + 1, mmm); + } + + const weekdayLongFormat = getDTF(locale, { ...dtOptions, weekday: 'long' }); + const weekdayShortFormat = getDTF(locale, { ...dtOptions, weekday: 'short' }); + + + + // 2024-01-01 is Monday (1). 2024-01-07 is Sunday (7). + for (let d = 1; d <= 7; d++) { + const date = new Date(Date.UTC(2024, 0, d)); + const longStr = weekdayLongFormat.format(date).toLowerCase(); + const shortStr = weekdayShortFormat.format(date).toLowerCase(); + + addEntry(longStr, d, wkd, www); + if (shortStr !== longStr) addEntry(shortStr, d, www); + } + + try { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + const yesterday = rtf.format(-1, 'day').toLowerCase(); + const today = rtf.format(0, 'day').toLowerCase(); + const tomorrow = rtf.format(1, 'day').toLowerCase(); + + const addEvent = (val: string, logic: string) => { + if (!val) return; + events[val] = logic; + const unaccented = removeAccents(val); + if (unaccented !== val) events[unaccented] = logic; + }; + + addEvent(yesterday, 'yesterday'); + addEvent(today, 'today'); + addEvent(tomorrow, 'tomorrow'); + } catch { + // safe fallback if RelativeTimeFormat is unsupported + } + + const sortByLength = (a: string, b: string) => b.length - a.length; + const dedup = (arr: string[]) => [...new Set(arr)]; + + return { + snippets: { + mon: dedup(mon).sort(sortByLength).join('|'), + mmm: dedup([...mon, ...mmm]).sort(sortByLength).join('|'), + wkd: dedup(wkd).sort(sortByLength).join('|'), + www: dedup([...wkd, ...www]).sort(sortByLength).join('|') + }, + events, + localeMap: map + }; +}); diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 8e74bb24..780b0417 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -294,13 +294,10 @@ export class Tempo { /** get first Canonical name of a supplied locale */ private static _locale = (locale?: string) => { + const global = Context.global; let language: string | undefined; - try { // lookup locale - language = canonicalLocale(locale!); - } catch (error) { } // catch unknown locale - - const global = Context.global; + if (locale) language = canonicalLocale(locale); return language ?? global?.navigator?.languages?.[0] ?? // fallback to current first navigator.languages[] @@ -323,7 +320,8 @@ export class Tempo { if (isEmpty(mergedOptions)) return; // Apply options using extendState - extendState(shape, mergedOptions); + const patternsDirty = extendState(shape, mergedOptions); + if (patternsDirty) setPatterns(shape); // Side-effects const newSphere = Tempo._setSphere(shape, mergedOptions); @@ -395,10 +393,21 @@ export class Tempo { if (discovery.terms) this.extend(asArray(discovery.terms)); - // 3. Process Formats - if (discovery.formats) { - shape.config.formats = shape.config.formats.extend(discovery.formats) as t.FormatRegistry; - if (!isSandbox) registryUpdate('FORMAT', discovery.formats); + // 3. Process Registry + let registryOpts = discovery.registry ?? {}; + + if (discovery.formats) + registryOpts = { ...registryOpts, formats: discovery.formats }; + + if (discovery.locales) + registryOpts = { ...registryOpts, locales: discovery.locales }; + + if (Object.keys(registryOpts).length > 0) { + opts = { ...opts, registry: registryOpts }; + if (!isSandbox) { + if (registryOpts.formats) registryUpdate('FORMAT', registryOpts.formats); + if (registryOpts.locales) registryUpdate('LOCALE', registryOpts.locales); + } } // 4. Process Plugins @@ -623,6 +632,7 @@ export class Tempo { static create(options: t.Options = {}): typeof Tempo { const SandboxTempo = class extends (this as any) { static [Symbol.toStringTag] = 'TempoSandbox'; + static [$IsBase] = false; } const discovery = options.discovery; @@ -689,10 +699,13 @@ export class Tempo { setLogLevel(options.debug ?? Default?.debug ?? LOG.Info); const rt = getRuntime(); - rt.state = undefined; // force fresh state - const state = init(options); + const isBase = !!this[$IsBase]; + if (isBase) rt.state = undefined; // force fresh state + + const baseState = isBase ? undefined : Object.getPrototypeOf(this)[$Internal](); + const state = init(options, isBase, baseState); (state as any)._count = 0; - if (this[$IsBase]) { + if (isBase) { _global = state; } else { ClassStates.set(this, state); @@ -750,7 +763,11 @@ export class Tempo { timeZone, locale, discovery: normalizedDiscovery, - formats: config.formats ?? enumify(STATE.FORMAT, false), + format: config.format ?? { localize: config.localize ?? false }, + registry: { + formats: config.registry?.formats ?? config.formats ?? enumify(STATE.FORMAT, false), + locales: config.registry?.locales ?? config.locales ?? proxify(STATE.LOCALE, true, true) + }, scope: 'global', catch: options.catch ?? config.catch ?? false }, @@ -938,9 +955,14 @@ export class Tempo { return indexedArray(list, key => list.find((t: any) => t.key === key || t.scope === key)) as unknown as Secure[]> & Record>; } - /** static Tempo.formats (registry) */ + /** static Tempo.registry */ + static get registry() { + return Tempo.config.registry; + } + + /** @deprecated Use Tempo.registry.formats instead */ static get formats() { - return Tempo.config.formats; + return Tempo.config.registry.formats; } /** static Tempo properties getter */ @@ -1270,7 +1292,7 @@ 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.registry.formats[key])) return this.#setLazy(target, key, () => this.format(key as t.Format))?.(); } else { if (!ensureModule(this, 'TermsModule')) return undefined; @@ -1316,7 +1338,7 @@ export class Tempo { #discover(host: 'term' | 'fmt', target: any) { if (!_lifecycle.ready) return; if (host === 'fmt') { - ownKeys(this.#local.config.formats).forEach(key => { + ownKeys(this.#local.config.registry.formats).forEach(key => { if (isString(key)) this.#setLazy(target, key, () => this.format(key as t.Format)); }); } else { @@ -1495,6 +1517,8 @@ export class Tempo { (this.#local as any)._id = (this.constructor as any)[$Internal]()._count++; const self = unwrap(this); this.#local.config = markConfig(Object.create(classState.config)); + if (classState.config.registry) this.#local.config.registry = Object.create(classState.config.registry); + if (classState.config.format) this.#local.config.format = Object.create(classState.config.format); Object.assign(this.#local.config, { scope: 'local' }); this.#local.parse = markConfig(Object.create(classState.parse)); diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index 05aba891..ba60756e 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -231,7 +231,10 @@ export namespace Internal { /** pivot year for two-digit years */ pivot: number; /** hemisphere for term.qtr or term.szn */ sphere: enums.COMPASS | undefined; /** internationalization configuration (relativeTime, etc.) */ intl?: IntlOptions; + /** top-level shortcut to enable `parse.localize` and `format.localize` */ localize?: boolean; /** parse planner configuration (layoutOrder, etc.) */ planner?: PlannerOptions; + /** formatting engine configuration */ format?: { localize?: boolean }; + /** parsing engine configuration */ parse?: { localize?: boolean }; /** Precision to measure timestamps (ms | us) */ timeStamp?: TimeStamp; /** initialization strategy ('auto'|'strict'|'defer') */mode?: enums.MODE; /** regional date-parsing configuration */ monthDay: MonthDay | boolean; @@ -240,7 +243,8 @@ export namespace Internal { /** custom date aliases (events). */ event: Event | RegistryOption; /** custom time aliases (periods). */ period: Period | RegistryOption; /** noise words to ignore during parsing. */ ignore: Ignore; - /** custom format strings to merge in the FORMAT enum */formats: Property; + /** @deprecated Provide configuration inside `registry: { formats: ... }` */ formats: Property; + /** custom data augmentation registries */ registry?: { formats?: Property, locales?: Record> }; /** plugins to be automatically extended */ plugins: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; /** supplied value to parse */ value: DateTime; /** @internal temporary anchor used during parsing */ anchor: any; @@ -308,16 +312,20 @@ export namespace Internal { /** @internal lazy delegator for terms */ term?: any; /** @internal localized Master Guard scanner */ guard?: { test(str: string): boolean }; /** @internal localized Noise Word scanner */ ignorePattern?: RegExp; + /** @internal flag for localized parsing */ localize?: boolean; + /** @internal reverse-lookup map for localized parsing */localeMap?: Record; } /** drop the parse-only Options */ export type OptionsKeep = Omit /** Instance configuration derived from supply, storage, and discovery. */ - export interface Config extends Required> { + export interface Config extends Required> { /** license key for premium features */ license?: string; /** configuration (global | local) */ scope: 'global' | 'local'; - /** pre-configured format strings */ formats: FormatRegistry; + /** formatting engine configuration */ format: { localize?: boolean }; + /** parsing engine configuration */ parse: { localize?: boolean }; + /** custom data augmentation registries */ registry: { formats: FormatRegistry, locales: Record> }; /** index-signature */ readonly [key: string]: any; } @@ -330,7 +338,12 @@ export namespace Internal { /** aliases to merge in the Number-Word dictionary */ numbers?: Record; /** term plugins to be registered via Tempo.addTerm() */terms?: TermPlugin | TermPlugin[]; /** internationalization configuration (relativeTime, etc.) */intl?: IntlOptions; - /** custom format strings to merge in the FORMAT dictionary */formats?: Property; + /** top-level shortcut to enable localization */ localize?: boolean; + /** formatting engine configuration */ format?: { localize?: boolean }; + /** parsing engine configuration */ parse?: { localize?: boolean }; + /** @deprecated Provide configuration inside `registry: { formats: ... }` */formats?: Property; + /** @deprecated Provide configuration inside `registry: { locales: ... }` */locales?: Record>; + /** custom data augmentation registries */ registry?: { formats?: Property, locales?: Record> }; /** noise words to ignore during parsing via Tempo.ignore() */ignore?: Ignore; /** plugins to be automatically extended via Tempo.extend() */plugins?: (TempoPlugin | TermPlugin) | (TempoPlugin | TermPlugin)[]; } diff --git a/packages/tempo/src/tempo.version.ts b/packages/tempo/src/tempo.version.ts index 2b76663d..0b403b24 100644 --- a/packages/tempo/src/tempo.version.ts +++ b/packages/tempo/src/tempo.version.ts @@ -5,4 +5,4 @@ * ⚠️ This file is auto-updated by `npm run build:version` (see `bin/update-version.mjs`). * Do NOT edit manually — your changes will be overwritten on the next build. */ -export const TEMPO_VERSION = '3.0.2'; +export const TEMPO_VERSION = '3.1.0'; diff --git a/packages/tempo/test/core/alias-engine.test.ts b/packages/tempo/test/core/alias-engine.test.ts index 92432131..4e71f5d3 100644 --- a/packages/tempo/test/core/alias-engine.test.ts +++ b/packages/tempo/test/core/alias-engine.test.ts @@ -1,6 +1,5 @@ import { AliasEngine } from '#tempo/engine/engine.alias.js'; import { logTempo } from '#tempo/support/support.util.js'; -import { vi, afterEach } from 'vitest'; describe('AliasEngine', () => { afterEach(() => { @@ -9,9 +8,9 @@ describe('AliasEngine', () => { it('registers and resolves string and function aliases', () => { const engine = new AliasEngine(); engine.registerAliases('evt', [['foo', 'bar']]); - expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); + expect(engine.resolveAlias('evt0_0')?.value).toBe('bar'); engine.registerAliases('per', [['noon', function () { return '12:00'; }]]); - expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); + expect(engine.resolveAlias('per0_0')?.value).toBe('12:00'); expect(engine.resolveAlias('per0_0')?.isClock).toBe(true); }); diff --git a/packages/tempo/test/discrete/format.test.ts b/packages/tempo/test/discrete/format.test.ts index 64f76925..546d2ad3 100644 --- a/packages/tempo/test/discrete/format.test.ts +++ b/packages/tempo/test/discrete/format.test.ts @@ -56,9 +56,9 @@ describe('Tempo.format() refinements', () => { expect(tPM.format('{HH} {mer}')).toBe('10 pm'); }) - it('does not add am/pm if {MER} is already present', () => { - expect(tAM.format('{HH} {MER}')).toBe('10 AM'); - expect(tPM.format('{HH} {MER}')).toBe('10 PM'); + it('does not add am/pm if {mer:upper} is already present', () => { + expect(tAM.format('{HH} {mer:upper}')).toBe('10 AM'); + expect(tPM.format('{HH} {mer:upper}')).toBe('10 PM'); }) it('does not add am/pm for {hh} (24-hour)', () => { @@ -69,4 +69,24 @@ describe('Tempo.format() refinements', () => { expect(tAM.format('{HH} on {mon}')).toBe('10am on May'); }) }) + + describe('auto-localize', () => { + const t = new Tempo('2024-05-20T10:00:00Z', { locale: 'fr-FR', format: { localize: true } }); + + it('should evaluate non-localized tokens normally', () => { + expect(t.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-20'); + expect(t.format('{hh}:{mi}')).toBe('10:00'); + }) + + it('should automatically localize native Intls', () => { + expect(t.format('{mon}')).toBe('mai'); // instead of 'May' + expect(t.format('{www}')).toBe('lun.'); // instead of 'Mon' + expect(t.format('{mon:upper}')).toBe('MAI'); // casing correctly applied after localization + }) + + it('should automatically localize Terms', () => { + expect(t.format('{#tod}')).toBe('Milieu de la matinée'); + expect(t.format('{#timeOfDay}')).toBe('Milieu de la matinée'); + }) + }) }) diff --git a/packages/tempo/test/discrete/parse.locale.test.ts b/packages/tempo/test/discrete/parse.locale.test.ts new file mode 100644 index 00000000..94eab8d3 --- /dev/null +++ b/packages/tempo/test/discrete/parse.locale.test.ts @@ -0,0 +1,63 @@ +import { Tempo } from '#tempo'; +import { resetRuntime } from '#tempo/support'; + +describe('Localized Parsing', () => { + beforeEach(() => { + resetRuntime(); + Tempo.init({ mode: 'strict', timeZone: 'UTC', calendar: 'iso8601' }); + }); + + afterEach(() => { + // no-op + }); + + it('should parse French months correctly when localized parsing is enabled', () => { + const t = new Tempo('15 janv. 2024', { locale: 'fr-FR', localize: true }); + + expect(t.isValid).toBe(true); + expect(t.mm).toBe(1); + expect(t.dd).toBe(15); + expect(t.yy).toBe(2024); + + const t2 = new Tempo('15 février 2024', { locale: 'fr-FR', localize: true }); + expect(t2.isValid).toBe(true); + expect(t2.mm).toBe(2); + }); + + it('should parse French months without trailing punctuation', () => { + const t = new Tempo('15 janv 2024', { locale: 'fr-FR', localize: true }); + expect(t.isValid).toBe(true); + expect(t.mm).toBe(1); + }); + + it('should parse French weekdays correctly', () => { + // 15 Jan 2024 is a Monday (lundi) + // We expect parsing "mercredi" (Wednesday) without a date to resolve to the current week's Wednesday. + const t = new Tempo('mercredi', { locale: 'fr-FR', localize: true, anchor: '2024-01-15T12:00:00+00:00[UTC]' }); + expect(t.isValid).toBe(true); + expect(t.dow).toBe(3); // Wednesday + }); + + it('should parse French relative events (yesterday, today, tomorrow)', () => { + const t1 = new Tempo('hier', { locale: 'fr-FR', localize: true, anchor: '2024-01-15T12:00:00+00:00[UTC]' }); + expect(t1.isValid).toBe(true); + expect(t1.day).toBe(14); + + const t2 = new Tempo("aujourd’hui", { locale: 'fr-FR', localize: true, anchor: '2024-01-15T12:00:00+00:00[UTC]' }); + expect(t2.isValid).toBe(true); + const currentDay = Temporal.Now.zonedDateTimeISO('UTC').day; + expect(t2.day).toBe(currentDay); + + const t3 = new Tempo('demain', { locale: 'fr-FR', localize: true, anchor: '2024-01-15T12:00:00+00:00[UTC]' }); + expect(t3.isValid).toBe(true); + expect(t3.day).toBe(16); + }); + + it('should NOT parse French dates if localize is false (default)', () => { + expect(() => new Tempo('15 janv. 2024', { locale: 'fr-FR' })).toThrow(); + }); + + it('should fail to parse English dates if localized parsing is active for French', () => { + expect(() => new Tempo('15 January 2024', { locale: 'fr-FR', localize: true })).toThrow(); + }); +}); diff --git a/packages/tempo/test/discrete/standalone_parse.test.ts b/packages/tempo/test/discrete/standalone_parse.test.ts index bc5a1c32..bf3bb834 100644 --- a/packages/tempo/test/discrete/standalone_parse.test.ts +++ b/packages/tempo/test/discrete/standalone_parse.test.ts @@ -1,6 +1,5 @@ import { parse } from '#tempo/parse'; import { Tempo } from '#tempo'; -import { Temporal } from '@js-temporal/polyfill'; import { registryReset } from '#tempo/support'; beforeEach(() => { diff --git a/packages/tempo/test/engine/meridiem.test.ts b/packages/tempo/test/engine/meridiem.test.ts index 3c20e667..29519b94 100644 --- a/packages/tempo/test/engine/meridiem.test.ts +++ b/packages/tempo/test/engine/meridiem.test.ts @@ -8,9 +8,9 @@ describe('Meridiem (AM/PM) parsing and formatting', () => { expect(new Tempo('2024-05-20 15:00').format('{mer}')).toBe('pm'); }) - test('uppercase meridiem {MER}', () => { - expect(new Tempo('2024-05-20 03:00').format('{MER}')).toBe('AM'); - expect(new Tempo('2024-05-20 15:00').format('{MER}')).toBe('PM'); + test('uppercase meridiem {mer:upper}', () => { + expect(new Tempo('2024-05-20 03:00').format('{mer:upper}')).toBe('AM'); + expect(new Tempo('2024-05-20 15:00').format('{mer:upper}')).toBe('PM'); }) test('12-hour clock with meridiem', () => { diff --git a/packages/tempo/test/engine/month-day.test.ts b/packages/tempo/test/engine/month-day.test.ts index c733599d..f7efead1 100644 --- a/packages/tempo/test/engine/month-day.test.ts +++ b/packages/tempo/test/engine/month-day.test.ts @@ -1,8 +1,4 @@ import { Tempo } from '#tempo'; -import { ParseModule } from '#tempo/parse'; - -// Ensure ParseModule is loaded for date component parsing -Tempo.extend(ParseModule); describe('Tempo: Month-Day Parsing (Ambiguity Support)', () => {