diff --git a/CHANGELOG.md b/CHANGELOG.md index f7113dd1..a4f7ff06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.4.0] - 2026-04-24 + +### Added +- **Sandbox Factory Mode**: Introduced `Tempo.create()`, a static factory method for creating isolated `Tempo` subclasses with independent configurations and registries, preventing global state leakage. +- **Layout Controller Framework**: Added a classification-based layout controller to `engine.layout`, enabling future input-aware parsing optimizations. + +### Changed +- **Layout Order Resolver**: Extracted layout-ordering logic into a dedicated module to improve maintainability and testability. +- **Module Path Flattening**: Relocated core modules to `src/module/` for a flatter, more intuitive internal architecture. + +### Fixed +- **Determinism Coverage**: Added comprehensive unit tests for layout resolution and multi-pair swap handling. + + +## [2.3.0] - 2026-04-22 + +### Added +- **Standalone Parse Engine**: Extracted the natural language engine into a standalone `parse()` function for instance-free datetime resolution. +- **Noise Filtering**: Added an `ignore` option to strip irrelevant words during string parsing. +- **Backtracking Security**: Implemented `Match.backtrack` safety guards in the snippet registry. +- **Ecosystem Installation Guide**: Released comprehensive installation instructions for Node.js, Deno, and standard browser environments. + +### Changed +- **Automatic Context Sync**: Hemisphere settings now automatically synchronize with timezone updates. +- **State Optimization**: Refactored the internal parser state machine for reduced memory usage. +- **Interactive Playground**: Enhanced the browser-based demo with live timezone selectors and real-time clock updates. + +### Fixed +- **Resolution Resilience**: Hardened the resolution loop with safety valves to prevent infinite loops in extreme date ranges. +- **Type Safety**: Hardened TypeScript definitions for all `parse` and `term` resolution functions. + + ## [2.2.6] - 2026-04-20 ### Added diff --git a/package.json b/package.json index 785719dc..7a355b28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tempo-monorepo", - "version": "2.4.0", + "version": "2.5.0", "private": true, "description": "Magma Computing Monorepo", "repository": { diff --git a/packages/library/package.json b/packages/library/package.json index 05be87d4..6ea28741 100644 --- a/packages/library/package.json +++ b/packages/library/package.json @@ -1,6 +1,6 @@ { "name": "@magmacomputing/library", - "version": "2.4.0", + "version": "2.5.0", "description": "Shared utility library for Tempo", "author": "Magma Computing Solutions", "license": "MIT", diff --git a/packages/library/src/browser/mapper.library.ts b/packages/library/src/browser/mapper.library.ts index 1e4e81bb..0ad3618a 100644 --- a/packages/library/src/browser/mapper.library.ts +++ b/packages/library/src/browser/mapper.library.ts @@ -1,6 +1,6 @@ import { asObject } from '#library/object.library.js'; import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isNullish } from '#library/type.library.js'; +import { isNullish } from '#library/assertion.library.js'; import { instant } from '#library/temporal.library.js'; import { getHemisphere } from '#library/international.library.js'; diff --git a/packages/library/src/browser/tapper.class.ts b/packages/library/src/browser/tapper.class.ts index 5d8566c2..d94ffca8 100644 --- a/packages/library/src/browser/tapper.class.ts +++ b/packages/library/src/browser/tapper.class.ts @@ -1,5 +1,6 @@ import { enumify } from '#library/enumerate.library.js'; -import { isEmpty, isFunction, type ValueOf } from '#library/type.library.js'; +import { isEmpty, isFunction } from '#library/assertion.library.js'; +import type { ValueOf } from '#library/type.library.js'; /** * A Wrapper Class around HammerJS. diff --git a/packages/library/src/browser/webstore.class.ts b/packages/library/src/browser/webstore.class.ts index 9b10a6dc..25fbeca8 100644 --- a/packages/library/src/browser/webstore.class.ts +++ b/packages/library/src/browser/webstore.class.ts @@ -1,6 +1,7 @@ import { distinct, ownEntries } from '#library/primitive.library.js'; import { stringify, objectify } from '#library/serialize.library.js'; -import { asType, isEmpty, isNullish, isString } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isEmpty, isNullish, isString } from '#library/assertion.library.js'; import type { Property, ValueOf } from '#library/type.library.js'; const STORAGE = { diff --git a/packages/library/src/common.index.ts b/packages/library/src/common.index.ts index 01774210..708a9afe 100644 --- a/packages/library/src/common.index.ts +++ b/packages/library/src/common.index.ts @@ -3,6 +3,7 @@ */ export * from './common/array.library.js'; +export * from './common/assertion.library.js'; export * from './common/buffer.library.js'; export * from './common/cipher.class.js'; export * from './common/class.library.js'; diff --git a/packages/library/src/common/array.library.ts b/packages/library/src/common/array.library.ts index d586c0f0..9cd33b03 100644 --- a/packages/library/src/common/array.library.ts +++ b/packages/library/src/common/array.library.ts @@ -1,7 +1,7 @@ -import { asString } from '#library/coercion.library.js'; +import { asString, nullishToValue } from '#library/coercion.library.js'; import { extract, ownEntries } from '#library/primitive.library.js'; import { stringify } from '#library/serialize.library.js'; -import { isNumber, isDate, isObject, isDefined, isUndefined, isFunction, nullToValue } from '#library/type.library.js'; +import { isNumber, isDate, isObject, isDefined, isUndefined, isFunction } from '#library/assertion.library.js'; import type { Property } from '#library/type.library.js'; // adapted from https://jsbin.com/insert/4/edit?js,output @@ -48,8 +48,8 @@ export function sortBy>(...keys: (PropertyKey | SortBy)[]) if (result === 0) { // no need to look further if result !== 0 const dir = key.dir === 'desc' ? -1 : 1; const field = key.field + (key.index ? `[${key.index}]` : ''); - const valueA = extract(left, field, nullToValue(key.default, 0)); - const valueB = extract(right, field, nullToValue(key.default, 0)); +const valueA = extract(left, field, nullishToValue(key.default, 0)); + const valueB = extract(right, field, nullishToValue(key.default, 0)); switch (true) { case isNumber(valueA) && isNumber(valueB): diff --git a/packages/library/src/common/assertion.library.ts b/packages/library/src/common/assertion.library.ts new file mode 100644 index 00000000..96cd7081 --- /dev/null +++ b/packages/library/src/common/assertion.library.ts @@ -0,0 +1,109 @@ +import { sym } from '#library/symbol.library.js'; +import { getType, protoType, asType } from '#library/type.library.js'; +import type { Type, Primitive, Nullish, Temporals, Property, GetType } from '#library/type.library.js'; + +/** assert value is one of a list of Types */ +export const isType = (obj: unknown, ...types: Type[]): obj is T => types.includes(getType(obj)); + +/** Type-Guards: assert \ is of \ */ +export const isPrimitive = (obj?: unknown): obj is Primitive => isType(obj, 'String', 'Number', 'BigInt', 'Boolean', 'Symbol', 'Undefined', 'Void', 'Null', 'Empty'); +export const isReference = (obj?: unknown): obj is Object => !isPrimitive(obj); +export const isIterable = (obj: unknown): obj is Iterable => Symbol.iterator in Object(obj) && !isString(obj); + +export const isString = (obj?: T): obj is Extract => isType(obj, 'String'); +export const isNumber = (obj?: T): obj is Extract => isType(obj, 'Number'); +export const isFiniteNumber = (obj?: T): obj is Extract => isType(obj, 'Number') && isFinite(obj as number); + +/** test if can convert String to Numeric */ +export function isNumeric(str?: any): boolean { + const type = typeof str; + switch (type) { + case 'number': return isFinite(str); + case 'bigint': return true; + case 'string': { + const val = str.trim(); + if (val.length === 0) return false; + return /^-?[0-9]+n$/.test(val) || (!isNaN(parseFloat(val)) && isFinite(Number(val))); + } + default: return false; + } +} +export const isInteger = (obj?: T): obj is Extract => isType(obj, 'BigInt'); +export const isIntegerLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^-?[0-9]+n$/.test(obj as string); +export const isDigit = (obj?: T): obj is Extract => isType(obj, 'Number', 'BigInt'); +export const isBoolean = (obj?: T): obj is Extract => isType(obj, 'Boolean'); +export const isArray = (obj: unknown): obj is T[] => isType(obj, 'Array'); +export const isArrayLike = (obj: any): obj is ArrayLike => protoType(obj) === 'Object' && 'length' in obj && Object.keys(obj).every(key => key === 'length' || !isNaN(Number(key))); +export const isObject = (obj?: T): obj is Extract => isType(obj, 'Object'); +export const isDate = (obj?: T): obj is Extract => isType(obj, 'Date'); +export const isRegExp = (obj?: T): obj is Extract => isType(obj, 'RegExp'); +export const isRegExpLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^\/.*\/$/.test(obj as string); +export const isSymbol = (obj?: T): obj is Extract => isType(obj, 'Symbol'); +export const isSymbolFor = (obj?: T): obj is Extract => isType(obj, 'Symbol') && Symbol.keyFor(obj) !== undefined; +export const isPropertyKey = (obj?: unknown): obj is PropertyKey => isType(obj, 'String', 'Number', 'Symbol'); + +export const isNull = (obj?: T): obj is Extract => isType(obj, 'Null'); +export const isNullish = (obj: T): obj is Extract => isType(obj, 'Null', 'Undefined', 'Void', 'Empty'); +export const isUndefined = (obj?: T): obj is undefined => isType(obj, 'Undefined', 'Void', 'Empty'); +export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); + +export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); +export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); +export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); +export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); +export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); +export const isError = (err?: T): err is Extract => isType(err, 'Error'); + +export const isTemporal = (obj: T): obj is Extract => protoType(obj).startsWith('Temporal.') || (!!(globalThis as any).Temporal && ( + (obj as any) instanceof (globalThis as any).Temporal.Instant || + (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime || + (obj as any) instanceof (globalThis as any).Temporal.PlainDate || + (obj as any) instanceof (globalThis as any).Temporal.PlainTime || + (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime || + (obj as any) instanceof (globalThis as any).Temporal.Duration || + (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth || + (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay +)); + +export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Instant') || (!!obj && typeof (obj as any).toZonedDateTimeISO === 'function' && isUndefined((obj as any).timeZoneId)); +export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.ZonedDateTime') || (!!obj && typeof (obj as any).toInstant === 'function' && isDefined((obj as any).timeZoneId)); +export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDate') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && isDefined((obj as any).daysInMonth) && isUndefined((obj as any).hour) && isUndefined((obj as any).minute) && isUndefined((obj as any).second) && isUndefined((obj as any).nanosecond)); +export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainTime') || (!!obj && typeof (obj as any).toPlainDateTime === 'function' && isUndefined((obj as any).daysInMonth)); +export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.PlainDateTime') || (!!obj && typeof (obj as any).toZonedDateTime === 'function' && isUndefined((obj as any).timeZoneId) && (isDefined((obj as any).hour) || isDefined((obj as any).minute) || isDefined((obj as any).second) || isDefined((obj as any).nanosecond))); +export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration) || (!!obj && (obj as any)[Symbol.toStringTag] === 'Temporal.Duration'); +export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( + 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || + 'hours' in obj || 'minutes' in obj || 'seconds' in obj || + 'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj +)); +export const isZonedDateTimeLike = (obj: T): obj is Extract => isString(obj) || isZonedDateTime(obj) || (isObject(obj) && ( + 'year' in obj || 'month' in obj || 'day' in obj || 'hour' in obj || 'minute' in obj || 'second' in obj || + 'millisecond' in obj || 'microsecond' in obj || 'nanosecond' in obj || 'monthCode' in obj || 'offset' in obj || 'timeZone' in obj || 'calendar' in obj +)); +export const isPlainYearMonth = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainYearMonth') || (!!(globalThis as any).Temporal?.PlainYearMonth && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); +export const isPlainMonthDay = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainMonthDay') || (!!(globalThis as any).Temporal?.PlainMonthDay && (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay); + +// non-standard Objects +export const isEnum = >(obj?: T): obj is Extract> => isType(obj, 'Enumify'); +export const isPledge = (obj?: T): obj is Extract> => isType(obj, 'Pledge'); + +/** assert value for secure() */ +export const isExtensible = (obj: any): obj is any => !!(obj?.[sym.$Extensible]); +export const isTarget = (obj: any): obj is any => !!(obj?.[sym.$Target]); + +/** object has no values */ +export const isEmpty = (obj?: T) => false + || isNullish(obj) + || (isObject(obj) && (Reflect.ownKeys(obj).length === 0)) + || (isString(obj) && (obj.trim().length === 0)) + || Number.isNaN(obj as any) + || (isArray(obj) && (obj.length === 0)) + || (isSet(obj) && (obj.size === 0)) + || (isMap(obj) && (obj.size === 0)) + +export function assertCondition(condition: boolean, message?: string): asserts condition { + if (!condition) + throw new Error(message); +} +export function assertString(str: unknown): asserts str is string { assertCondition(isString(str), `Invalid string: ${str}`) }; +export function assertNever(val: never): asserts val is never { throw new Error(`Unexpected object: ${val}`) }; diff --git a/packages/library/src/common/coercion.library.ts b/packages/library/src/common/coercion.library.ts index 542ad1e9..4bf5df88 100644 --- a/packages/library/src/common/coercion.library.ts +++ b/packages/library/src/common/coercion.library.ts @@ -1,5 +1,6 @@ import { clone, stringify } from '#library/serialize.library.js'; -import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, asType, isNumber } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isIntegerLike, isArrayLike, isDefined, isInteger, isIterable, isNullish, isString, isUndefined, isNumber, isNumeric } from '#library/assertion.library.js'; /** Coerce {value} into {value[]} ( if not already ), with optional {fill} Object */ export function asArray(arr: Exclude, string> | undefined): T[]; @@ -52,25 +53,6 @@ export function asInteger(str?: T) { } } -/** test if can convert String to Numeric */ -export function isNumeric(str?: string | number | bigint) { - const arg = asType(str); - - switch (arg.type) { - case 'Number': - case 'BigInt': - return true; - - case 'String': - return isIntegerLike(arg.value) - ? true // is Number | BigInt - : !isNaN(asNumber(str)) && isFinite(str as number) // test if Number - - default: - return false; - } -} - /** return as Number if possible, else original String */ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { switch (true) { @@ -87,3 +69,7 @@ export const ifNumeric = (str: string | number | bigint, stripZero = false) => { return str as string; // non-numeric String → as-is } } + +export const nullishToZero = (obj: T) => obj ?? 0; +export const nullishToEmpty = (obj: T) => obj ?? ''; +export const nullishToValue = (obj: T, value: R) => obj ?? value; diff --git a/packages/library/src/common/enumerate.library.ts b/packages/library/src/common/enumerate.library.ts index aaca13ef..f8a74a13 100644 --- a/packages/library/src/common/enumerate.library.ts +++ b/packages/library/src/common/enumerate.library.ts @@ -1,10 +1,9 @@ -import { secure } from '#library/utility.library.js'; -import { asType, isNumber } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isNumber } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; -import { proxify } from '#library/proxy.library.js'; +import { secure, proxify } from '#library/proxy.library.js'; import { Serializable } from '#library/class.library.js'; import { memoizeMethod } from '#library/function.library.js'; -import lib from '#library/symbol.library.js'; import type { Property, Index, KeyOf, ValueOf, EntryOf, Invert, LooseKey } from '#library/type.library.js'; declare module '#library/type.library.js' { @@ -79,7 +78,7 @@ function value(val: any) { * ```typescript * const Status = enumify(['Active', 'Inactive', 'Pending']); * console.log(Status.Active); // 0 - * console.log(Status.has('Active')); // true + * console.log(Status.has('Active')); // true * console.log(Status.keys()); // ['Active', 'Inactive', 'Pending'] * ``` */ @@ -109,7 +108,6 @@ export function enumify(this: any, list: T, frozen = true): any { } const target = Object.create(proto, Object.getOwnPropertyDescriptors(stash)); - if (!frozen) Object.defineProperty(target, lib.$Extensible, { value: true, enumerable: false }); return proxify(target, true, frozen); // proxy is ALWAYS frozen (read-only), but target is only 'locked' if requested } diff --git a/packages/library/src/common/function.library.ts b/packages/library/src/common/function.library.ts index 6d27ffdd..f363b5f1 100644 --- a/packages/library/src/common/function.library.ts +++ b/packages/library/src/common/function.library.ts @@ -1,5 +1,5 @@ -import { secure } from '#library/utility.library.js'; -import { isUndefined, type Property } from '#library/type.library.js'; +import { secure } from '#library/proxy.library.js'; +import type { Property } from '#library/type.library.js'; // https://medium.com/codex/currying-in-typescript-ca5226c85b85 type PartialTuple = @@ -36,7 +36,7 @@ type Curry = * Handles BigInt, Map, Set, Function, Undefined, and Circular refs. */ function serialize(val: any, seen = new WeakSet()): string { - return JSON.stringify(val, function(this: any, key: string, value: any) { + return JSON.stringify(val, function (this: any, key: string, value: any) { if (value === undefined) return '\u0000__undefined__\u0000'; if (typeof value === 'bigint') return `bigint:${value}`; if (typeof value === 'function') return `function:${value.name || 'anonymous'}`; diff --git a/packages/library/src/common/logify.class.ts b/packages/library/src/common/logify.class.ts index 0cec5dd4..77bd128e 100644 --- a/packages/library/src/common/logify.class.ts +++ b/packages/library/src/common/logify.class.ts @@ -1,31 +1,53 @@ import { Immutable } from '#library/class.library.js'; -import lib, { markConfig } from '#library/symbol.library.js'; -import { asType, isObject, isEmpty, type ValueOf } from '#library/type.library.js'; +import { sym, markConfig } from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isObject, isEmpty } from '#library/assertion.library.js'; +import { enumify } from '#library/enumerate.library.js'; +import type { ValueOf, KeyOf } from '#library/type.library.js'; +export const LOG = enumify(['Off', 'Error', 'Warn', 'Info', 'Debug', 'Trace']); +export type LOG = ValueOf +export type LogLevel = KeyOf + +/** @internal console method names keyed by internal identifiers (not exported; see LOG enum for public API) */ const Method = { Log: 'log', Info: 'info', Warn: 'warn', Debug: 'debug', + Trace: 'trace', Error: 'error', -} as const +} as const; + +/** @internal severity levels mapped to Method names for gating logic, derived from LOG */ +const Level = { + [Method.Error]: LOG.Error, + [Method.Warn]: LOG.Warn, + [Method.Info]: LOG.Info, + [Method.Log]: LOG.Info, + [Method.Debug]: LOG.Debug, + [Method.Trace]: LOG.Trace, +} as const; +/** logging severity levels for Logify output control */ /** * provide standard logging methods to the console for a class */ @Immutable export class Logify { #name: string; - #opts: Logify.Constructor = { [lib.$Logify]: true }; + #opts: Logify.Constructor = { [sym.$Logify]: true }; /** * if {catch:true} then show a warning on the console and return * otherwise show an error on the console and re-throw the error */ #trap(method: Logify.Method, ...msg: any[]) { - const config = (isObject(msg[0]) && (msg[0] as any)[lib.$Logify] === true) ? msg.shift() : this.#opts; - - if (method === Method.Debug && !config.debug) return; + const config = (isObject(msg[0]) && (msg[0] as any)[sym.$Logify] === true) ? msg.shift() : this.#opts; + const currentLevel = (typeof config.debug === 'number') + ? config.debug + : (config.debug === true ? LOG.Trace : LOG.Info); + const methodLevel = Level[method] ?? 0; const output = msg.map(m => { if (m instanceof Error) return m.message; @@ -43,7 +65,7 @@ export class Logify { return String(m); }).filter(s => !isEmpty(s)).join(' '); - if (!config.silent && !isEmpty(output)) + if (!config.silent && !isEmpty(output) && methodLevel <= currentLevel) (console as any)[method](`${this.#name}: ${output}`); if (method === Method.Error && !config.catch) { @@ -61,6 +83,7 @@ export class Logify { /** console.info */ info = (...msg: any[]) => this.#trap(Method.Info, ...msg); /** console.warn */ warn = (...msg: any[]) => this.#trap(Method.Warn, ...msg); /** console.debug */ debug = (...msg: any[]) => this.#trap(Method.Debug, ...msg); + /** console.trace */ trace = (...msg: any[]) => this.#trap(Method.Trace, ...msg); /** console.error */ error = (...msg: any[]) => this.#trap(Method.Error, ...msg); constructor(self?: Logify.Constructor | string, opts = {} as Logify.Constructor) { @@ -89,9 +112,20 @@ export namespace Logify { export type Method = ValueOf export interface Constructor { - debug?: boolean | undefined, + /** + * Logging verbosity: `boolean | number`. + * - `true` maps to `LOG.Trace`, enabling trace-level logging + * - `false` (or unset) maps to `LOG.Info` + * - numeric values map directly to `LOG` levels + * + * Note: numeric `0` (`LOG.Off`) suppresses all console emission, including + * `console.error`. Errors can still be rethrown when `catch: false`, but no + * error log is emitted. Use `true` or a higher numeric level to ensure errors + * are logged to the console. + */ + debug?: boolean | number | undefined, catch?: boolean | undefined, silent?: boolean | undefined, - [lib.$Logify]?: boolean | undefined + [sym.$Logify]?: boolean | undefined } } \ No newline at end of file diff --git a/packages/library/src/common/object.library.ts b/packages/library/src/common/object.library.ts index 0eb3728f..378ddd39 100644 --- a/packages/library/src/common/object.library.ts +++ b/packages/library/src/common/object.library.ts @@ -1,5 +1,5 @@ import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { isObject, isArray, isReference, isFunction, isDefined, isNullish } from '#library/type.library.js'; +import { isObject, isArray, isReference, isFunction, isDefined, isNullish } from '#library/assertion.library.js'; import type { Extend, Property } from '#library/type.library.js'; /** remove quotes around property names */ diff --git a/packages/library/src/common/pledge.class.ts b/packages/library/src/common/pledge.class.ts index 64bcfd40..573bb108 100644 --- a/packages/library/src/common/pledge.class.ts +++ b/packages/library/src/common/pledge.class.ts @@ -2,10 +2,10 @@ import { Logify } from '#library/logify.class.js'; import { markConfig } from '#library/symbol.library.js'; import { asArray } from '#library/coercion.library.js'; import { ifDefined } from '#library/object.library.js'; -import { secure } from '#library/utility.library.js'; +import { secure } from '#library/proxy.library.js'; import { cleanify } from '#library/serialize.library.js'; import { Immutable } from '#library/class.library.js'; -import { isEmpty, isObject } from '#library/type.library.js'; +import { isEmpty, isObject } from '#library/assertion.library.js'; declare module '#library/type.library.js' { interface TypeValueMap { @@ -175,16 +175,16 @@ export namespace Pledge { onResolve?: Pledge.Resolve | Pledge.Resolve[] | undefined; onReject?: Pledge.Reject | Pledge.Reject[] | undefined; onSettle?: Pledge.Settle | Pledge.Settle[] | undefined; - debug?: boolean | undefined; - catch?: boolean | undefined; - silent?: boolean | undefined; + debug?: Logify.Constructor["debug"]; + catch?: Logify.Constructor["catch"]; + silent?: Logify.Constructor["silent"]; } export interface Status { tag?: string | undefined; - debug?: boolean | undefined; - catch?: boolean | undefined; - silent?: boolean | undefined; + debug?: Pledge.Constructor["debug"]; + catch?: Pledge.Constructor["catch"]; + silent?: Pledge.Constructor["silent"]; state: symbol; settled?: T | undefined; error?: any | undefined; diff --git a/packages/library/src/common/primitive.library.ts b/packages/library/src/common/primitive.library.ts index 08704595..204674a0 100644 --- a/packages/library/src/common/primitive.library.ts +++ b/packages/library/src/common/primitive.library.ts @@ -1,5 +1,4 @@ -import sym from '#library/symbol.library.js'; -import { isEmpty } from '#library/type.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; /** @@ -9,41 +8,70 @@ import type { Obj, KeyOf, ValueOf, EntryOf } from '#library/type.library.js'; * These functions have NO dependencies on array, object, or reflection libraries. */ +/** + * ## unwrap + * Traverse a Proxy chain and return the underlying raw target object. + * Hardened against prototype-climbing bugs and cyclic $Target chains. + */ +export function unwrap(obj: T): T { + let curr = obj as any; + let depth = 0; + const maxDepth = 50; // Guard against infinite loops on cyclic or self-referential $Target chains + + // Use direct reads so proxy get-traps can surface synthetic $Target values. + while (curr) { + const next = curr[sym.$Target] ?? (curr as any).$Target; + if (!next || depth >= maxDepth) break; + curr = next; + depth++; + } + return curr; +} + /** Tuple of enumerable entries with string | symbol keys */ export function ownEntries(json: T, all = false): EntryOf[] { if (!json || typeof json !== 'object') return [] as EntryOf[]; - const unwrap = (obj: any): any => { - let curr = obj; - while (curr && curr[sym.$Target]) { - curr = curr[sym.$Target]; + const tgt = unwrap(json); + if (!all) { + const keys = Reflect.ownKeys(tgt); + const entries: [PropertyKey, any][] = []; + for (const k of keys) { + const desc = Object.getOwnPropertyDescriptor(tgt, k); + if (desc && desc.enumerable) entries.push([k, (tgt as any)[k]]); } - return curr; + return entries as EntryOf[]; } - const getOwn = (obj: any): [PropertyKey, any][] => { - const tgt = unwrap(obj); - return Reflect.ownKeys(tgt) - .filter(key => Object.getOwnPropertyDescriptor(tgt, key)?.enumerable) - .map(key => [key, tgt[key]]); + const levels: any[] = []; + const limit = 50; + let depth = 0; + let curr: any = tgt; + + while (curr && curr !== Object.prototype && depth++ < limit) { + levels.push(unwrap(curr)); + curr = Object.getPrototypeOf(curr); } - if (!all) return getOwn(json) as EntryOf[]; + const entries: [PropertyKey, any][] = []; + const seen = new Set(); - const levels: [PropertyKey, any][][] = []; - const limit = 50; - let depth = 0; - let proto: any = json; + for (const level of levels.reverse()) { + const keys = Reflect.ownKeys(level); + + for (const k of keys) { + if (seen.has(k)) continue; - do { - const t = unwrap(proto); - const lvl = getOwn(proto); - if (lvl.length) levels.push(lvl); - proto = Object.getPrototypeOf(t); - } while (proto && proto !== Object.prototype && ++depth < limit); + const desc = Object.getOwnPropertyDescriptor(level, k); + if (!desc || !desc.enumerable) continue; + + seen.add(k); + entries.push([k, (tgt as any)[k]]); + } + } - return [...new Map(levels.reverse().flat()).entries()] as EntryOf[]; + return entries as EntryOf[]; } /** Array of all enumerable PropertyKeys */ @@ -58,7 +86,7 @@ export function ownValues(json: T, all = false): ValueOf[] { /** Get nested value using dot or bracket notation */ export function extract(obj: any, path: string | number, dflt?: T): T { - if (isEmpty(path)) return obj as T; + if (path === undefined || path === null || path === '') return obj as T; if (obj === null || typeof obj !== 'object') return dflt as T; return path diff --git a/packages/library/src/common/proxy.library.ts b/packages/library/src/common/proxy.library.ts index 5df1998d..4219d610 100644 --- a/packages/library/src/common/proxy.library.ts +++ b/packages/library/src/common/proxy.library.ts @@ -1,152 +1,172 @@ -import lib from '#library/symbol.library.js'; +import { sym } from '#library/symbol.library.js'; import { allObject } from '#library/reflection.library.js'; -import { secure } from '#library/utility.library.js'; -import { isDefined, isFunction, isSymbol, registerType, type Constructor, type Type } from '#library/type.library.js'; +import { deepFreeze } from '#library/utility.library.js'; +import { unwrap } from '#library/primitive.library.js'; +import { isFunction, isSymbol, isDefined } from '#library/assertion.library.js'; +import { registerType, type Constructor } from '#library/type.library.js'; + +const boundMethodCache = new WeakMap>(); + +/** internal options for the unified proxy engine */ +type ProxyOptions = { + frozen?: boolean; // read-only Proxy (throws on set/delete) + lock?: boolean; // deep-freeze the target object + appendOnly?: boolean; // allow adding properties, but not changing existing ones + onGet?: (key: string | symbol, target: any) => any; // callback for property discovery + keys?: (string | symbol)[]; // fixed set of keys (for virtual objects) + bind?: boolean; // bind methods to the target + skip?: WeakSet; // objects to skip during deep-freeze +} -/** Stealth Proxy pattern to allow for iteration and logging over a Frozen object */ -export function proxify(target: T, frozen = true, lock = frozen) { - const tgt = (target as any)[lib.$Target] ?? target; // unwrap if it's already a proxy +/** + * ## factory + * The unified internal engine for all Proxy creation in the library. + * Handles unwrapping, Proxy invariants, discovery, and security. + */ +function factory(target: T, options: ProxyOptions = {}): T { + const { frozen, lock, appendOnly, onGet, keys, bind, skip } = options; + const pending = new Set(); let cachedJSON: any; - registerType(tgt as Constructor); // auto-register with global type system + // 1. Unwrap recursive proxies and resolve the true target + const tgt = unwrap(target); - if (lock) secure(tgt); + // 2. Harden the target if requested + if (lock) deepFreeze(tgt, skip ? { skip } : undefined); + registerType(tgt as Constructor); - return new Proxy(tgt, { + const handler: ProxyHandler = { isExtensible: (t) => Reflect.isExtensible(t), - preventExtensions: (t) => Reflect.preventExtensions(t), - getOwnPropertyDescriptor: (_, key) => Reflect.getOwnPropertyDescriptor(tgt, key), - getPrototypeOf: () => Reflect.getPrototypeOf(tgt), - ownKeys: () => Reflect.ownKeys(tgt), - has: (_, key) => Reflect.has(tgt, key), - deleteProperty: (_, key) => { - if (frozen) throw new TypeError(`Cannot delete property '${String(key)}' from a frozen object.`); - return Reflect.deleteProperty(tgt, key); - }, - set: (_, key, val) => { - if (frozen) throw new TypeError(`Cannot set property '${String(key)}' on a frozen object.`); - return Reflect.set(tgt, key, val); + getPrototypeOf: (t) => Reflect.getPrototypeOf(t), + setPrototypeOf: (t, proto) => { + if (frozen) throw new TypeError('Security: Prototype mutation attempt on protected object'); + return Reflect.setPrototypeOf(t, proto); }, - get: (_, key) => { - if (key === lib.$Target) - return tgt; // found the 'stop' marker - if (frozen && (key === lib.$Inspect || key === 'toJSON')) { // two special properties require virtual closures - const own = Object.getOwnPropertyDescriptor(tgt, key); - if (own && isFunction(own.value)) // if object already has its own toJSON, return - return own.value; - - if (!cachedJSON) // otherwise, create a virtual closure - cachedJSON = () => allObject(tgt); // resolve & memoize for subsequent calls - - return cachedJSON; // return the memoized closure + getOwnPropertyDescriptor: (t, k) => { + if (keys && !keys.includes(k)) return undefined; + if (keys) { + if (onGet && !isSymbol(k) && !pending.has(k)) { + pending.add(k); + try { + const value = onGet(k, t); + if (isDefined(value)) return { enumerable: true, configurable: true, value }; + } finally { + pending.delete(k); + } + } + return { enumerable: true, configurable: true }; } + return Reflect.getOwnPropertyDescriptor(t, k); + }, - const val = Reflect.get(tgt, key); - return (frozen && isFunction(val)) // if the value is a function - ? val.bind(tgt) // bind it to the target - : val; // else return the value + ownKeys: (t) => { + if (keys) return keys as string[]; + if (onGet && !pending.has(sym.$Discover)) { + pending.add(sym.$Discover); + try { onGet(sym.$Discover, t); } finally { pending.delete(sym.$Discover); } + } + return Reflect.ownKeys(t); }, - }) as T -} -/** Stealth Proxy pattern to allow for on-demand lazy property discovery and registration */ -export function delegate(target: T, onGet: (key: string | symbol, target: T) => any, readonly = true) { - const pending = new Set(); // recursion guard + has: (t, k) => (keys ? keys.includes(k) : Reflect.has(t, k)), - return new Proxy(target, { - isExtensible: (t) => Reflect.isExtensible(t), - preventExtensions: (t) => Reflect.preventExtensions(t), - getOwnPropertyDescriptor: (t, key) => Reflect.getOwnPropertyDescriptor(t, key), - getPrototypeOf: (t) => Reflect.getPrototypeOf(t), - setPrototypeOf: (t, proto) => { - if (readonly) throw new TypeError('Cannot set prototype of a read-only delegator.'); - return Reflect.setPrototypeOf(t, proto); + deleteProperty: (t, k) => { + if (frozen) throw new TypeError(`Cannot delete property '${String(k)}' from a protected object.`); + if (appendOnly && Reflect.has(t, k)) throw new Error(`Security: Deletion attempt on protected key '${String(k)}'`); + return Reflect.deleteProperty(t, k); }, - deleteProperty: (t, key) => { - if (readonly) throw new TypeError(`Cannot delete property '${String(key)}' from a read-only delegator.`); - return Reflect.deleteProperty(t, key); + defineProperty: (t, k, d) => { + if (frozen) throw new TypeError(`Cannot define property '${String(k)}' on a frozen object.`); + if (appendOnly && Reflect.has(t, k)) throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); + return Reflect.defineProperty(t, k, d); }, - defineProperty: (t, key, desc) => { - if (readonly) throw new TypeError(`Cannot define property '${String(key)}' on a read-only delegator.`); - return Reflect.defineProperty(t, key, desc); + set: (t, k, v, r) => { + if (frozen && r === result) throw new TypeError(`Cannot set property '${String(k)}' on a frozen object.`); + if (appendOnly) { + const isTruncating = Array.isArray(t) && k === 'length' && v < t.length; + if (isTruncating) throw new Error('Security: Truncation attempt on protected array.'); + if (!(Array.isArray(t) && k === 'length') && Reflect.has(t, k)) + throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); + } + return Reflect.set(t, k, v, r); }, - set: (t, key, val) => { - if (readonly) throw new TypeError(`Cannot set property '${String(key)}' on a read-only delegator.`); - return Reflect.set(t, key, val); - }, + get: (t, k, r) => { + if (k === sym.$Target) return r === result ? t : undefined; - ownKeys: (t) => { - if (!pending.has(lib.$Discover)) { - pending.add(lib.$Discover); + // Virtualization for serialization + if (frozen && (k === sym.$Inspect || k === 'toJSON')) { + const own = Object.getOwnPropertyDescriptor(t, k); + if (own && isFunction(own.value)) return own.value; + if (!cachedJSON) cachedJSON = () => allObject(t); + return cachedJSON; + } + + if (keys && !keys.includes(k)) return undefined; + + // Property Discovery + if (onGet && !isSymbol(k) && !Reflect.has(t, k) && !pending.has(k)) { + pending.add(k); try { - onGet(lib.$Discover, t); // full discovery phase + const val = onGet(k, t); + if (isDefined(val)) return val; } finally { - pending.delete(lib.$Discover); + pending.delete(k); } - } - return Reflect.ownKeys(t); - }, - get: (t, key) => { - // bypass for symbols or properties already in the chain (or currently resolving) - if (isSymbol(key) || Reflect.has(t, key) || pending.has(key)) - return Reflect.get(t, key); - - pending.add(key); // mark as resolving - try { - const val = onGet(key, t); // discovery phase - if (val !== undefined) return val; // return early if evaluation was handled - } finally { - pending.delete(key); // resolve complete + // silent mark to avoid redundant discovery + if (Reflect.isExtensible(t) && !Reflect.has(t, k)) + Object.defineProperty(t, k, { value: undefined, writable: true, enumerable: false, configurable: true }); } - // silently mark on target to avoid redundant discovery even if not found - if (Reflect.isExtensible(t) && !Reflect.has(t, key)) - Object.defineProperty(t, key, { value: undefined, enumerable: false, configurable: true }); + const val = Reflect.get(t, k, r); + if (bind && isFunction(val)) { + const desc = Object.getOwnPropertyDescriptor(t, k); + if (desc && !desc.configurable && !desc.writable) return val; + let perTargetCache = boundMethodCache.get(val); + if (!perTargetCache) { + perTargetCache = new WeakMap(); + boundMethodCache.set(val, perTargetCache); + } - return Reflect.get(t, key); + const cachedBound = perTargetCache.get(t); + if (cachedBound) return cachedBound; + const bound = val.bind(t); + perTargetCache.set(t, bound); + return bound; + } + return val; } - }) as T; -} + }; + const result = new Proxy(tgt, handler) as T; + return result; +} -/** internal helper to check for array truncation attempts */ -const isTruncating = (t: any, k: PropertyKey, v: any) => Array.isArray(t) && k === 'length' && v < t.length; +/** Stealth Proxy pattern to allow for on-demand lazy property discovery and registration */ +export function proxify(target: T, frozen = true, lock = frozen, skip = new WeakSet()) { + return factory(target, { frozen, lock, skip, bind: frozen }); +} -/** internal helper to verify that a mutation is safe (Closed for Modification, Open for Extension) */ -const assertSafe = (t: any, k: PropertyKey, v: any) => { - if (isTruncating(t, k, v)) throw new Error('Security: Truncation attempt on protected array.'); - if (Array.isArray(t) && k === 'length') return; - if (Reflect.has(t, k)) throw new Error(`Security: Mutation attempt on protected key '${String(k)}'`); +/** Create a dynamic Proxy where property access is forwarded to a discovery callback */ +export function delegate(target: T, onGet: (key: string | symbol, target: T) => any, readonly = true) { + return factory(target, { onGet, frozen: readonly }); } -/** - * ## secureRef - * Wrap an object or array in a protective Proxy that follows 'Closed for Modification, Open for Extension'. - * Allows adding new properties/elements, but prevents overwriting or deleting existing ones. - */ +/** Wrap an object in a protective Proxy that allows extension but prevents modification */ export function secureRef(target: T): T { - return new Proxy(target, { - get(t, k) { - if (k === lib.$Target) return t; - return Reflect.get(t, k); - }, - set(t, k, v) { - assertSafe(t, k, v); - return Reflect.set(t, k, v); - }, - defineProperty(t, k, d) { - assertSafe(t, k, d.value); - return Reflect.defineProperty(t, k, d); - }, - deleteProperty(t, k) { - throw new Error(`Security: Deletion attempt on protected key '${String(k)}'`); - }, - setPrototypeOf() { - throw new Error(`Security: Prototype mutation attempt on protected object`); - } - }); + return factory(target, { appendOnly: true }); +} + +/** Deep-freeze an object and wrap it in a loudly-throwing read-only Proxy */ +export function secure(obj: T, skip = new WeakSet()): T { + return factory(obj, { frozen: true, lock: true, skip, bind: true }); +} + +/** Create a virtual Proxy where fixed keys are mapped to a callback function */ +export function delegator(keys: K[] | Record, fn: (prop: K) => any): Record { + const keyList = Array.isArray(keys) ? keys : Reflect.ownKeys(keys) as K[]; + return factory({} as any, { keys: keyList, onGet: fn as any, frozen: true }); } diff --git a/packages/library/src/common/reflection.library.ts b/packages/library/src/common/reflection.library.ts index d62b6ab3..efaf53ae 100644 --- a/packages/library/src/common/reflection.library.ts +++ b/packages/library/src/common/reflection.library.ts @@ -1,5 +1,6 @@ import { distinct, ownKeys, ownEntries } from '#library/primitive.library.js'; -import { asType, getType, isEmpty, isFunction, isPrimitive } from '#library/type.library.js'; +import { asType, getType } from '#library/type.library.js'; +import { isEmpty, isFunction, isPrimitive } from '#library/assertion.library.js'; import type { Obj, KeyOf, Primitives } from '#library/type.library.js'; /** mutate Object | Array by excluding values with specified primitive 'types' */ diff --git a/packages/library/src/common/serialize.library.ts b/packages/library/src/common/serialize.library.ts index 7286cd51..f6464212 100644 --- a/packages/library/src/common/serialize.library.ts +++ b/packages/library/src/common/serialize.library.ts @@ -1,8 +1,9 @@ import { curry } from '#library/function.library.js'; import { ownKeys, ownValues, ownEntries } from '#library/primitive.library.js'; -import { isType, asType, isEmpty, isDefined, isUndefined, isNullish, isString, isObject, isArray, isFunction, isSymbolFor, isSymbol } from '#library/type.library.js'; -import sym from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isType, isEmpty, isDefined, isUndefined, isNullish, isString, isObject, isArray, isFunction, isSymbolFor, isSymbol } from '#library/assertion.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Obj, Type } from '#library/type.library.js'; export const Registry = (globalThis as any)[sym.$SerializerRegistry] ??= new Map(); @@ -11,9 +12,8 @@ export const Registry = (globalThis as any)[sym.$SerializerRegistry] ??= new Map export const registerSerializable = (name: string, cls: Function) => { const key = name.startsWith('$') ? name : `$${name}`; - if (Registry.has(key)) { + if (Registry.has(key)) throw new Error(`[registerSerializable] Collision: '${key}' is already registered with ${Registry.get(key)?.name || 'anonymous constructor'}`); - } Registry.set(key, cls); } diff --git a/packages/library/src/common/storage.library.ts b/packages/library/src/common/storage.library.ts index dfb27208..e9361fc3 100644 --- a/packages/library/src/common/storage.library.ts +++ b/packages/library/src/common/storage.library.ts @@ -1,6 +1,6 @@ import { objectify, stringify } from '#library/serialize.library.js'; import { CONTEXT, getContext } from '#library/utility.library.js'; -import { isDefined, isUndefined, isString } from '#library/type.library.js'; +import { isDefined, isUndefined, isString } from '#library/assertion.library.js'; const context = getContext(); diff --git a/packages/library/src/common/string.library.ts b/packages/library/src/common/string.library.ts index 2a32bad1..f04e71ae 100644 --- a/packages/library/src/common/string.library.ts +++ b/packages/library/src/common/string.library.ts @@ -1,6 +1,6 @@ -import { asNumber, asString, isNumeric } from '#library/coercion.library.js'; +import { asNumber, asString, nullishToValue } from '#library/coercion.library.js'; import { stringify } from '#library/serialize.library.js'; -import { isString, isObject, assertCondition, assertString, nullToValue } from '#library/type.library.js'; +import { isString, isObject, isNumeric, assertCondition, assertString } from '#library/assertion.library.js'; // General functions @@ -93,8 +93,8 @@ export const plural = (val: string | number | Record, word: stri type SingularUnit = T extends `${infer S}s` ? T extends `${string}${string}${string}${string}` - ? S - : T + ? S + : T : T; /** strip a plural suffix, if endsWith 's' */ @@ -134,7 +134,7 @@ export const strlen = (str: unknown, min * @returns fixed-length string padded on the left with fill-character */ export const pad = (nbr: string | number | bigint = 0, len = 2, fill?: string | number) => - nbr.toString().padStart(len, nullToValue(fill, isNumeric(nbr) ? '0' : ' ').toString()); + nbr.toString().padStart(len, nullishToValue(fill, isNumeric(nbr) ? '0' : ' ').toString()); /** pad a string with non-blocking spaces, to help right-align a display */ export const padString = (str: string | number | bigint, pad = 6) => diff --git a/packages/library/src/common/symbol.library.ts b/packages/library/src/common/symbol.library.ts index 9095bad4..424eccff 100644 --- a/packages/library/src/common/symbol.library.ts +++ b/packages/library/src/common/symbol.library.ts @@ -3,23 +3,18 @@ * These symbols utilize Symbol.for() to ensure consistency across module boundaries. */ -const sym = { - /** key to use for identifying the raw target of a Proxy */ - $Target: Symbol.for('$LibraryTarget'), - /** key to trigger full discovery of all lazy properties */ - $Discover: Symbol.for('$LibraryDiscover'), - /** key to identify objects that should remain extensible */ - $Extensible: Symbol.for('$LibraryExtensible'), - /** NodeJS custom inspection symbol for the Proxy pattern */ - $Inspect: Symbol.for('nodejs.util.inspect.custom'), - /** unique marker to identify a Logify configuration object */ - $Logify: Symbol.for('$LibraryLogify'), - /** key to identify the global type registry */ - $Registry: Symbol.for('$LibraryRegistry'), - /** key to identify the global registration hook */ - $Register: Symbol.for('$LibraryRegister'), - /** key to identify the global serialization registry */ - $SerializerRegistry: Symbol.for('$LibrarySerializerRegistry'), +export const $Target: unique symbol = Symbol.for('$LibraryTarget') as any; +export const $Discover: unique symbol = Symbol.for('$LibraryDiscover') as any; +export const $Extensible: unique symbol = Symbol.for('$LibraryExtensible') as any; +export const $Inspect: unique symbol = Symbol.for('nodejs.util.inspect.custom') as any; +export const $Logify: unique symbol = Symbol.for('$LibraryLogify') as any; +export const $Registry: unique symbol = Symbol.for('$LibraryRegistry') as any; +export const $Register: unique symbol = Symbol.for('$LibraryRegister') as any; +export const $SerializerRegistry: unique symbol = Symbol.for('$LibrarySerializerRegistry') as any; +export const $Identity: unique symbol = Symbol.for('$LibraryIdentity') as any; + +export const sym = { + $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register, $SerializerRegistry, $Identity } as const; /** identify and mark a Logify configuration object */ @@ -29,5 +24,3 @@ export function markConfig(obj: T): T { return obj; } - -export default sym; diff --git a/packages/library/src/common/temporal.library.ts b/packages/library/src/common/temporal.library.ts index 41fdf465..507fdb1d 100644 --- a/packages/library/src/common/temporal.library.ts +++ b/packages/library/src/common/temporal.library.ts @@ -4,7 +4,7 @@ */ import '#library/temporal.polyfill.js'; // ensure Temporal is available -import { isNumber } from '#library/type.library.js'; +import { isNumber, isString } from '#library/assertion.library.js'; /** return the current Temporal.Now.instant */ export function instant() { @@ -105,3 +105,16 @@ export function toInstant(epochNanoseconds: bigint): Temporal.Instant { return Temporal.Instant.fromEpochNanoseconds(epochNanoseconds); } +/** + * ## getTemporalIds + * Normalize TimeZone and Calendar inputs into a [timeZoneId, calendarId] tuple. + */ +export function getTemporalIds(tz: any, cal: any): [string, string] { + const rawTz = isString(tz) ? tz : ((tz as any)?.timeZoneId ?? (tz as any)?.id); + const rawCal = isString(cal) ? cal : ((cal as any)?.calendarId ?? (cal as any)?.id); + const fallbackTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + const tzId = (isString(rawTz) && rawTz.trim().length > 0) ? rawTz : fallbackTz; + const calId = (isString(rawCal) && rawCal.trim().length > 0) ? rawCal : 'iso8601'; + + return [tzId || 'UTC', calId || 'iso8601']; +} diff --git a/packages/library/src/common/type.library.ts b/packages/library/src/common/type.library.ts index b77a256c..723f3247 100644 --- a/packages/library/src/common/type.library.ts +++ b/packages/library/src/common/type.library.ts @@ -1,10 +1,11 @@ -import lib from '#library/symbol.library.js'; +import { sym } from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; const registry: Instance[] = []; // global types for getType /** the primitive type reported by toStringTag() */ -const protoType = (obj?: unknown) => { - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps +export const protoType = (obj?: unknown) => { + const raw = (obj && typeof obj === 'object') ? unwrap(obj as object) : obj; return Object.prototype.toString.call(raw).slice(8, -1) as Type; } @@ -18,7 +19,7 @@ const protoType = (obj?: unknown) => { * before calling getType() to ensure custom types are correctly identified. */ export const getType = (obj?: any, ...instances: Instance[]): Type => { - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps + const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps const type = protoType(raw); switch (true) { @@ -27,18 +28,18 @@ export const getType = (obj?: any, ...instances: Instance[]): Type => { case isClassConstructor(raw): return 'Class'; case typeof raw === 'function': return type; // catch all functional types (including AsyncFunction) - case type === 'Object': { - if (isArrayLike(raw)) return 'ArrayLike'; + // check for ArrayLike (e.g. {0:'a', 1:'b', length:2}) + if ('length' in raw && Object.keys(raw).every(key => key === 'length' || !isNaN(Number(key)))) return 'ArrayLike'; for (const inst of instances) { - const instRaw = (inst.class as any)?.[lib.$Target] ?? inst.class; + const instRaw = (inst.class as any)?.[sym.$Target] ?? inst.class; if (raw === instRaw || (instRaw && raw instanceof instRaw)) return inst.type as Type; } - const globalRegistry = (globalThis as any)[lib.$Registry] ?? []; + const globalRegistry = (globalThis as any)[sym.$Registry] ?? []; for (const inst of [...registry, ...globalRegistry]) { - const instRaw = (inst.class as any)?.[lib.$Target] ?? inst.class; + const instRaw = (inst.class as any)?.[sym.$Target] ?? inst.class; if (raw === instRaw || (instRaw && raw instanceof instRaw)) return inst.type as Type; } @@ -52,104 +53,14 @@ export const getType = (obj?: any, ...instances: Instance[]): Type => { /** return TypeValue object */ export const asType = (value?: T, ...instances: Instance[]) => ({ type: getType(value, ...instances), value } as TypeValue); -/** assert value is one of a list of Types */ -export const isType = (obj: unknown, ...types: Type[]): obj is T => types.includes(getType(obj)); - -/** Type-Guards: assert \ is of \ */ -export const isPrimitive = (obj?: unknown): obj is Primitive => isType(obj, 'String', 'Number', 'BigInt', 'Boolean', 'Symbol', 'Undefined', 'Void', 'Null', 'Empty'); -export const isReference = (obj?: unknown): obj is Object => !isPrimitive(obj); -export const isIterable = (obj: unknown): obj is Iterable => Symbol.iterator in Object(obj) && !isString(obj); - -export const isString = (obj?: T): obj is Extract => isType(obj, 'String'); -export const isNumber = (obj?: T): obj is Extract => isType(obj, 'Number') && isFinite(obj as number); -export const isInteger = (obj?: T): obj is Extract => isType(obj, 'BigInt'); -export const isIntegerLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^-?[0-9]+n$/.test(obj as string); -export const isDigit = (obj?: T): obj is Extract => isType(obj, 'Number', 'BigInt'); -export const isBoolean = (obj?: T): obj is Extract => isType(obj, 'Boolean'); -export const isArray = (obj: unknown): obj is T[] => isType(obj, 'Array'); -export const isArrayLike = (obj: any): obj is ArrayLike => protoType(obj) === 'Object' && 'length' in obj && Object.keys(obj).every(key => key === 'length' || !isNaN(Number(key))); -export const isObject = (obj?: T): obj is Extract => isType(obj, 'Object'); -export const isDate = (obj?: T): obj is Extract => isType(obj, 'Date'); -export const isRegExp = (obj?: T): obj is Extract => isType(obj, 'RegExp'); -export const isRegExpLike = (obj?: T): obj is Extract => isType(obj, 'String') && /^\/.*\/$/.test(obj as string); -export const isSymbol = (obj?: T): obj is Extract => isType(obj, 'Symbol'); -export const isSymbolFor = (obj?: T): obj is Extract => isType(obj, 'Symbol') && Symbol.keyFor(obj) !== undefined; -export const isPropertyKey = (obj?: unknown): obj is PropertyKey => isType(obj, 'String', 'Number', 'Symbol'); - -export const isNull = (obj?: T): obj is Extract => isType(obj, 'Null'); -export const isNullish = (obj: T): obj is Extract => isType(obj, 'Null', 'Undefined', 'Void', 'Empty'); -export const isUndefined = (obj?: T): obj is undefined => isType(obj, 'Undefined', 'Void', 'Empty'); -export const isDefined = (obj: T): obj is NonNullable => !isNullish(obj); - -export const isClass = (obj?: T): obj is Extract => isType(obj, 'Class'); -export const isFunction = (obj?: T): obj is Extract => isType(obj, 'Function', 'AsyncFunction'); -export const isPromise = (obj?: T): obj is Extract> => isType(obj, 'Promise'); -export const isMap = (obj?: T): obj is Extract> => isType(obj, 'Map'); -export const isSet = (obj?: T): obj is Extract> => isType(obj, 'Set'); -export const isError = (err?: T): err is Extract => isType(err, 'Error'); - -export const isTemporal = (obj: T): obj is Extract => protoType(obj).startsWith('Temporal.') || (!!(globalThis as any).Temporal && ( - (obj as any) instanceof (globalThis as any).Temporal.Instant || - (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime || - (obj as any) instanceof (globalThis as any).Temporal.PlainDate || - (obj as any) instanceof (globalThis as any).Temporal.PlainTime || - (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime || - (obj as any) instanceof (globalThis as any).Temporal.Duration || - (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth || - (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay -)); - -export const isInstant = (obj: T): obj is Extract => isType(obj, 'Temporal.Instant') || (!!(globalThis as any).Temporal?.Instant && (obj as any) instanceof (globalThis as any).Temporal.Instant); -export const isZonedDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.ZonedDateTime') || (!!(globalThis as any).Temporal?.ZonedDateTime && (obj as any) instanceof (globalThis as any).Temporal.ZonedDateTime); -export const isPlainDate = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDate') || (!!(globalThis as any).Temporal?.PlainDate && (obj as any) instanceof (globalThis as any).Temporal.PlainDate); -export const isPlainTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainTime') || (!!(globalThis as any).Temporal?.PlainTime && (obj as any) instanceof (globalThis as any).Temporal.PlainTime); -export const isPlainDateTime = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainDateTime') || (!!(globalThis as any).Temporal?.PlainDateTime && (obj as any) instanceof (globalThis as any).Temporal.PlainDateTime); -export const isDuration = (obj: T): obj is Extract => isType(obj, 'Temporal.Duration') || (!!(globalThis as any).Temporal?.Duration && (obj as any) instanceof (globalThis as any).Temporal.Duration); -export const isDurationLike = (obj: T): obj is Extract => isString(obj) || isDuration(obj) || (isObject(obj) && ( - 'years' in obj || 'months' in obj || 'weeks' in obj || 'days' in obj || - 'hours' in obj || 'minutes' in obj || 'seconds' in obj || - 'milliseconds' in obj || 'microseconds' in obj || 'nanoseconds' in obj -)); -export const isPlainYearMonth = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainYearMonth') || (!!(globalThis as any).Temporal?.PlainYearMonth && (obj as any) instanceof (globalThis as any).Temporal.PlainYearMonth); -export const isPlainMonthDay = (obj: T): obj is Extract => isType(obj, 'Temporal.PlainMonthDay') || (!!(globalThis as any).Temporal?.PlainMonthDay && (obj as any) instanceof (globalThis as any).Temporal.PlainMonthDay); - -// non-standard Objects -export const isEnum = >(obj?: T): obj is Extract> => isType(obj, 'Enumify'); -export const isPledge = (obj?: T): obj is Extract> => isType(obj, 'Pledge'); - -/** assert value for secure() */ -export const isExtensible = (obj: any): obj is any => !!(obj?.[lib.$Extensible]); -export const isTarget = (obj: any): obj is any => !!(obj?.[lib.$Target]); - -export const nullToZero = (obj: T) => obj ?? 0; -export const nullToEmpty = (obj: T) => obj ?? ''; -export const nullToValue = (obj: T, value: R) => obj ?? value; - -/** object has no values */ -export const isEmpty = (obj?: T) => false - || isNullish(obj) - || (isObject(obj) && (Reflect.ownKeys(obj).length === 0)) - || (isString(obj) && (obj.trim().length === 0)) - || (isNumber(obj) && (isNaN(obj) === true)) - || (isArray(obj) && (obj.length === 0)) - || (isSet(obj) && (obj.size === 0)) - || (isMap(obj) && (obj.size === 0)) - -export function assertCondition(condition: boolean, message?: string): asserts condition { - if (!condition) - throw new Error(message); -} -export function assertString(str: unknown): asserts str is string { assertCondition(isString(str), `Invalid string: ${str}`) }; -export function assertNever(val: never): asserts val is never { throw new Error(`Unexpected object: ${val}`) }; - /** * # resetRegistry * Clear the global type registry for test isolation and deterministic behavior. */ export const resetRegistry = () => { registry.length = 0; - if (Array.isArray((globalThis as any)[lib.$Registry])) { - (globalThis as any)[lib.$Registry].length = 0; + if (Array.isArray((globalThis as any)[sym.$Registry])) { + (globalThis as any)[sym.$Registry].length = 0; } }; const classRegex = /^\s*(class\s|\[native code\])/; // match class keyword OR native constructor @@ -158,7 +69,7 @@ const classRegex = /^\s*(class\s|\[native code\])/; // match class keyword O const isClassConstructor = (obj: any): boolean => { if (typeof obj !== 'function') return false; - const raw = (obj as any)?.[lib.$Target] ?? obj; // bypass Proxy traps + const raw = (obj as any)?.[sym.$Target] ?? obj; // bypass Proxy traps // Arrow functions do NOT have a prototype property, whereas traditional functions and classes DO. // This is a high-performance check to immediately classify arrow functions as non-classes. @@ -166,26 +77,40 @@ const isClassConstructor = (obj: any): boolean => { const name = (raw as any)?.name; const tag = raw?.[Symbol.toStringTag] ?? raw?.prototype?.[Symbol.toStringTag]; - // Absolute bypass for Tempo and Temporal identities (using global brands) - if (name === 'Tempo' || tag === 'Tempo' || (typeof tag === 'string' && (tag.startsWith('Temporal.') || tag.startsWith('Tempo.')))) return true; + // Absolute bypass for branded identities (using universal brand) + if (raw?.[sym.$Identity] || raw?.prototype?.[sym.$Identity]) { + return true; + } + if (typeof tag === 'string' && tag.startsWith('Temporal.')) { + return true; + } if (typeof tag === 'string' && tag.endsWith('Function')) return false; // check the tag directly to avoid misidentifying function as class - const globalRegistry = (globalThis as any)[lib.$Registry] ?? []; - if (globalRegistry.some((inst: any) => ((inst.class as any)?.[lib.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) return true; + const globalRegistry = (globalThis as any)[sym.$Registry] ?? []; + if (globalRegistry.some((inst: any) => ((inst.class as any)?.[sym.$Target] ?? inst.class) === raw || (name && inst.type === name) || (tag && inst.type === tag))) { + return true; + } // 3. Last resort: Inspection of constructor property & prototype (Transpilation-Safe) try { const str = Function.prototype.toString.call(raw); - if (classRegex.test(str)) return true; + if (classRegex.test(str)) { + return true; + } // ES6 classes have a non-writable prototype descriptor + // Note: Object.freeze() also makes the prototype non-writable, so we exclude frozen objects. const descriptor = Object.getOwnPropertyDescriptor(raw, 'prototype'); - if (descriptor && descriptor.writable === false) return true; + if (descriptor && descriptor.writable === false && !Object.isFrozen(raw)) { + return true; + } // if it's a function with a 'prototype' that is NOT an empty object (excluding its constructor), it's likely a class const proto = raw.prototype; - if (proto && Object.getOwnPropertyNames(proto).length > 2) return true; + if (proto && Object.getOwnPropertyNames(proto).length > 2) { + return true; + } } catch { return false; @@ -252,7 +177,7 @@ type toName = T extends undefined ? "Undefined" : T extends null ? "Null" : never -type Primitive = string | number | bigint | boolean | symbol | void | undefined | null // TODO: add composite (record & tuple) ? +export type Primitive = string | number | bigint | boolean | symbol | void | undefined | null // TODO: add composite (record & tuple) ? export type Primitives = toName /** Generic constructor type */ diff --git a/packages/library/src/common/utility.library.ts b/packages/library/src/common/utility.library.ts index 2c13e6a0..0f74c72d 100644 --- a/packages/library/src/common/utility.library.ts +++ b/packages/library/src/common/utility.library.ts @@ -1,5 +1,6 @@ import { ownValues } from '#library/primitive.library.js'; -import { isDefined, isPrimitive } from '#library/type.library.js'; +import { isDefined, isPrimitive } from '#library/assertion.library.js'; +import { sym } from '#library/symbol.library.js'; import type { Secure, ValueOf } from '#library/type.library.js'; /** General utility functions */ @@ -63,14 +64,40 @@ export const getContext = (): Context => { return { global, type: CONTEXT.Unknown }; } -/** deep-freeze an Array | Object to make it immutable (with recursion guard) */ -export function secure(obj: T, seen = new WeakSet()) { - if (isPrimitive(obj) || Object.isFrozen(obj) || seen.has(obj)) +/** Shared empty WeakSet sentinel to avoid allocations for default skip parameter */ +const EMPTY_SKIP = new WeakSet(); + +/** + * Deep-freeze an Array | Object to make it immutable (with recursion guard). + * + * @param obj - The object to freeze + * @param options - Optional configuration + * @param options.skip - Externally owned WeakSet of objects to skip during freezing; not mutated by this function (caller responsible for lifecycle) + * @returns The frozen object with Secure type + * + * @remarks + * - Internally maintains a `seen` WeakSet to track visited objects and prevent infinite recursion + * - The `skip` parameter is an opt-out mechanism for caller-controlled exclusions + * - Symbol `sym.$Extensible` objects are always skipped + */ +export function deepFreeze(obj: T, options?: { skip?: WeakSet }): Secure; +export function deepFreeze(obj: T, options?: { skip?: WeakSet }, seen?: WeakSet): Secure; +export function deepFreeze(obj: T, options?: { skip?: WeakSet } | WeakSet, seen: WeakSet = new WeakSet()): Secure { + // Support both old and new signatures for backward compatibility + const skip = (options instanceof WeakSet) ? options : (options?.skip ?? EMPTY_SKIP); + + if (isPrimitive(obj) || Object.isFrozen(obj) || seen.has(obj) || skip.has(obj)) + return obj as Secure; + + if ((obj as any)?.[Symbol.toStringTag] === 'Enumify') + return obj as Secure; + + if ((obj as any)[sym.$Extensible]) return obj as Secure; seen.add(obj); - ownValues(obj as any).forEach(val => secure(val, seen)); + ownValues(obj as any).forEach(val => deepFreeze(val, { skip }, seen)); return Object.freeze(obj) as Secure; } diff --git a/packages/library/test/common/logify.class.test.ts b/packages/library/test/common/logify.class.test.ts new file mode 100644 index 00000000..2609fb98 --- /dev/null +++ b/packages/library/test/common/logify.class.test.ts @@ -0,0 +1,71 @@ +import { Logify } from '#library/logify.class.js'; + +describe('Logify severity gating', () => { + test('defaults to Info level when debug is false/undefined', () => { + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + try { + const log = new Logify('LogifyTestDefault'); + log.info('info-visible'); + log.debug('debug-hidden'); + + expect(infoSpy).toHaveBeenCalledTimes(1); + expect(infoSpy).toHaveBeenCalledWith('LogifyTestDefault: info-visible'); + expect(debugSpy).not.toHaveBeenCalled(); + } finally { + infoSpy.mockRestore(); + debugSpy.mockRestore(); + } + }); + + test('enables trace level when debug is true', () => { + const traceSpy = vi.spyOn(console, 'trace').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + try { + const log = new Logify('LogifyTestTrace', { debug: true }); + log.trace('trace-visible'); + log.debug('debug-visible'); + + expect(traceSpy).toHaveBeenCalledTimes(1); + expect(traceSpy).toHaveBeenCalledWith('LogifyTestTrace: trace-visible'); + expect(debugSpy).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith('LogifyTestTrace: debug-visible'); + } finally { + traceSpy.mockRestore(); + debugSpy.mockRestore(); + } + }); + + test('uses numeric debug level directly', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + + try { + const log = new Logify('LogifyTestNumeric', { debug: 2 }); + log.warn('warn-visible'); + log.info('info-hidden'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith('LogifyTestNumeric: warn-visible'); + expect(infoSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + infoSpy.mockRestore(); + } + }); + + test('rethrows errors even when log emission is gated off', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const err = new Error('boom'); + + try { + const log = new Logify('LogifyTestRethrow', { debug: 0, catch: false }); + expect(() => log.error(err)).toThrow('LogifyTestRethrow: boom'); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/packages/library/test/common/pledge.class.test.ts b/packages/library/test/common/pledge.class.test.ts index e71b3acf..cb356ce3 100644 --- a/packages/library/test/common/pledge.class.test.ts +++ b/packages/library/test/common/pledge.class.test.ts @@ -65,4 +65,49 @@ describe('Pledge', () => { } }); + test('callback failures warn at default debug level (indirect Logify integration)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + + try { + const p = new Pledge({ + onResolve: () => { + throw new Error('resolve callback failed'); + } + }); + + p.resolve('ok'); + await p.promise; + await Promise.resolve(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain('Pledge callback failed'); + expect(debugSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + debugSpy.mockRestore(); + } + }); + + test('numeric debug level gates callback warning logs (indirect Logify integration)', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const p = new Pledge({ + debug: 0, + onResolve: () => { + throw new Error('resolve callback failed'); + } + }); + + p.resolve('ok'); + await p.promise; + await Promise.resolve(); + + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); + }); diff --git a/packages/library/test/common/proxy.library.test.ts b/packages/library/test/common/proxy.library.test.ts new file mode 100644 index 00000000..a1b5f2ca --- /dev/null +++ b/packages/library/test/common/proxy.library.test.ts @@ -0,0 +1,60 @@ +import { delegator, proxify } from '#library/proxy.library.js'; + +describe('proxy method binding', () => { + test('returns a stable bound wrapper for repeated method access', () => { + const target = { + value: 7, + getValue() { return this.value; } + }; + + const proxy = proxify(target, true, false); + const first = proxy.getValue; + const second = proxy.getValue; + + expect(first).toBe(second); + expect(first).not.toBe(target.getValue); + expect(first()).toBe(7); + }); + + test('preserves original function identity for non-configurable non-writable descriptors', () => { + const fixed = function(this: { value: number }) { return this.value; }; + const target = { value: 11 } as { value: number; fixed?: typeof fixed }; + + Object.defineProperty(target, 'fixed', { + value: fixed, + configurable: false, + writable: false, + enumerable: true, + }); + + const proxy = proxify(target, true, false) as typeof target & { fixed: typeof fixed }; + + expect(proxy.fixed).toBe(fixed); + expect(proxy.fixed()).toBe(11); + }); + + test('does not share bound wrappers across different proxied targets', () => { + const shared = function(this: { value: number }) { return this.value; }; + const a = { value: 3, getValue: shared }; + const b = { value: 9, getValue: shared }; + + const proxyA = proxify(a, true, false); + const proxyB = proxify(b, true, false); + + expect(proxyA.getValue).not.toBe(proxyB.getValue); + expect(proxyA.getValue()).toBe(3); + expect(proxyB.getValue()).toBe(9); + }); +}); + +describe('delegator', () => { + test('does not allow writes to overwrite delegated values', () => { + const proxy = delegator(['alpha'] as const, key => `${key}-value`) as Record<'alpha', string>; + + expect(proxy.alpha).toBe('alpha-value'); + expect(() => { + proxy.alpha = 'mutated'; + }).toThrow(/Cannot set property 'alpha' on a frozen object\./); + expect(proxy.alpha).toBe('alpha-value'); + }); +}); \ No newline at end of file diff --git a/packages/library/test/common/temporal_guards.test.ts b/packages/library/test/common/temporal_guards.test.ts index 2d760882..16d96caa 100644 --- a/packages/library/test/common/temporal_guards.test.ts +++ b/packages/library/test/common/temporal_guards.test.ts @@ -1,5 +1,4 @@ -import * as t from '#library/type.library.js'; -import '#library/temporal.polyfill.js'; +import * as t from '#library/assertion.library.js'; describe('Temporal Type Guards', () => { it('should identify Temporal.Instant', () => { diff --git a/packages/tempo/CHANGELOG.md b/packages/tempo/CHANGELOG.md index bf4fa258..9b85382e 100644 --- a/packages/tempo/CHANGELOG.md +++ b/packages/tempo/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2026-04-24 + +### Added +- **Layout Order Resolver Module**: Extracted layout-ordering decision logic from the Tempo class into a dedicated `engine.layout` module (`src/engine/engine.layout.ts`). This module provides deterministic functions for resolving parse layout order based on locale preference and maintains existing pair-swap semantics. +- **Layout Controller Framework**: Implemented a minimal controller-map infrastructure (`LayoutController` type, `createLayoutController`, `resolveLayoutClassificationOrder`) to enable future input-class pre-filtering and custom layout ordering without structural refactors. The framework currently has a single default classification that mirrors existing behavior. +- **Debug Layout Order Visibility**: Added optional debug output in `Tempo.#swapLayout` to emit the resolved layout order for diagnostics (when `debug: true`). + +### Changed +- **Internal Layout Resolution**: Refactored `Tempo.#swapLayout` to delegate ordering to the external resolver, improving separation of concerns and testability. +- **Alias Precedence**: User-defined `event` and `period` aliases now take precedence over built-in aliases when both patterns match. + +### Notes +- **API Impact**: No public API changes; layout-ordering behavior is byte-for-byte equivalent to prior releases. +- **Performance**: Layout resolution is still $O(n)$ where $n$ is the number of layout entries; controller infrastructure is optimized for future per-input classification without per-call overhead. +- **Guidance**: If needed, rename custom aliases to avoid overlap or remove the conflicting custom alias. + +## [2.4.0] - (Skipped) + +_Version 2.4.0 was not released; the project merged new functionality from 2.4.0 into 2.5.0._ + ## [2.3.0] - 2026-04-22 ### Added diff --git a/packages/tempo/bin/tsconfig.json b/packages/tempo/bin/tsconfig.json index 44cc5e82..6ea090dc 100644 --- a/packages/tempo/bin/tsconfig.json +++ b/packages/tempo/bin/tsconfig.json @@ -42,19 +42,25 @@ "../src/plugin/term/term.*.ts" ], "#tempo/duration": [ - "../src/plugin/module/module.duration.ts" + "../src/module/module.duration.ts" + ], + "#tempo/mutate": [ + "../src/module/module.mutate.ts" ], "#tempo/plugin/plugin.*.js": [ "../src/plugin/plugin.*.ts" ], - "#tempo/plugin/extend.*.js": [ - "../src/plugin/extend/extend.*.ts" + "#tempo/engine/*.js": [ + "../src/engine/*.ts" ], - "#tempo/plugin/module.*.js": [ - "../src/plugin/module/module.*.ts" + "#tempo/module/*.js": [ + "../src/module/*.ts" ], - "#tempo/plugin/term.*.js": [ - "../src/plugin/term/term.*.ts" + "#tempo/plugin/extend/*.js": [ + "../src/plugin/extend/*.ts" + ], + "#tempo/plugin/term/*.js": [ + "../src/plugin/term/*.ts" ], "#tempo/*.js": [ "../src/*.ts" diff --git a/packages/tempo/demo/3-modular-granular.html b/packages/tempo/demo/3-modular-granular.html index 514cb821..0e824ec6 100644 --- a/packages/tempo/demo/3-modular-granular.html +++ b/packages/tempo/demo/3-modular-granular.html @@ -24,7 +24,7 @@

⏳ Demo 3: Modular (Granular)

{ "imports": { "@magmacomputing/tempo/core": "../dist/core.index.js", - "@magmacomputing/tempo/format": "../dist/plugin/module/module.format.js" + "@magmacomputing/tempo/format": "../dist/module/module.format.js" } } diff --git a/packages/tempo/doc/Tempo.md b/packages/tempo/doc/Tempo.md deleted file mode 100644 index 12cc2cd6..00000000 --- a/packages/tempo/doc/Tempo.md +++ /dev/null @@ -1,398 +0,0 @@ -# Tempo Technical Documentation - -`Tempo` is a modern JavaScript utility class designed to simplify work with dates and times by wrapping the `Temporal` API. - -This project came about due to the need for a simple, yet powerful, way to parse (and manipulate) dates and times in JavaScript. -`Date.parse()` was not a good solution, as it is not locale-aware, does not handle relative strings well, does not handle time zones well, and is not implemented in a standard way across all JavaScript runtimes. - -## Table of Contents -1. [Installation](#installation) -2. [Parsing](#parsing) -3. [Formatting](#formatting) -4. [Manipulation](#manipulation) -5. [Plugin System](#plugin-system) -6. [Ticker (Optional Plugin)](#ticker-optional-plugin) -7. [Terms (Built-in Plugin)](#plugin-terms) -8. [Context & Configuration](#context--configuration) -9. [Library Functionality](#library-functionality) -10. [API Reference](./tempo.api.md) -11. [Cookbook](./tempo.cookbook.md) -12. [Debugging](./tempo.debugging.md) - ---- - -### Browser (Import Maps) - -Tempo is an ESM-first library. You can use it in the browser without a build step using a suggested `importmap`. We provide an [importmap.json](../importmap.json) artifact in the package root for automated tools, or you can cut-and-paste the following into your HTML: - -```html - -``` - -### Browser (Script Tag) - -For legacy environments or simple prototypes, use the single-file bundle: - -```html - - -``` - -> [!TIP] -> **CDN Versioning**: The examples above use pinned versions (`@magmacomputing/tempo@2`, `@magmacomputing/library@2`, `@js-temporal/polyfill@0.5`) for production stability. To use the latest releases, you can omit the version string from every URL (e.g., remove `@2` from all Magma entries and `@0.5` from the polyfill). Ensure all `@magmacomputing/...` entries resolve to the same release to avoid mixed-version loading. - ---- - -## Installation - -```bash -npm install @magmacomputing/tempo -``` - ---- - -## ✨ What's New in v2.1.2 (Stabilized) -The **v2.1.2** release represents the stabilization of the modular architecture and the introduction of advanced relational math: - -- **Modular Architecture**: Tempo is now split into **Core** (lite) and **Full** (batteries-included) versions. Features like Tickers and Durations are now side-effectable plugins. -- **Relational Math**: Shifting by terms (e.g., `.add({ '#quarter': 1 })`) now uses **Cycle Preservation**, maintaining your relative offset within semantic cycles. -- **Scan-and-Consume Guard**: A high-performance internal matching engine that enables $O(1)$ instantiation even with dozens of active terminology plugins. -- **Logify & Symbol Internalization**: Internal diagnostics and lifecycle hooks are now protected by context-aware Symbols, preventing state leakage and ensuring monorepo compatibility. - ---- - -> [!IMPORTANT] -> `Tempo` requires an environment with native `Temporal` support (Modern Runtimes like Node.js 20+, Deno, or Bun). -> If your environment is older, you must provide your own polyfill. - -### Build Target - -Tempo is compiled to **ES2022**. This target supports modern JavaScript features like **Private Class Fields** (`#property`) while maintaining compatibility with the vast majority of modern browsers and server-side runtimes (Node 20+, Deno, Bun). - -### Polyfilling (if required) - -If you need to support older environments, we recommend `@js-temporal/polyfill`: - -```bash -npm install @js-temporal/polyfill -``` - -Then, import it at the very top of your application entry point: - -```typescript -import '@js-temporal/polyfill'; -``` - -### 💻 Server-Side (Node.js, Deno, Bun) - -`Tempo` is a native ESM package. In modern runtimes, simply import the class: - -```typescript -import { Tempo } from '@magmacomputing/tempo'; - -const t = new Tempo('next Friday'); -console.log(t.format('{dd} {mon} {yyyy}')); -``` - -### 🌐 Browser Usage - -```html - -``` ---- - -## Parsing - -`Tempo`'s strongest feature is its flexible parsing engine. It can interpret: - -- **ISO Strings**: `2024-05-20T10:00:00Z` -- **Short Dates**: `20-May`, `May 20` (locale-aware) -- **Relative Strings**: `next Monday`, `last Friday`, `2 days ago` -- **Numbers/BigInt**: Unix timestamps in milliseconds or nanoseconds -- **Temporal Objects**: `ZonedDateTime`, `PlainDate`, etc. - -### Snippets & Layouts -The parsing engine uses a library of RegEx patterns. -You can extend these patterns a) globally (via `Tempo.init()`) or b) per instance (via options to `new Tempo`. - -`Tempo` also supports **Event** and **Period** aliases. These can be simple strings or functions that return a value to be parsed. When using functions, ensure you use the `function` keyword to maintain proper `this` binding to the `Tempo` instance. - -```typescript -Tempo.extend({ - event: { - 'birthday': '20 May', - 'tomorrow': function () { return this.add({ days: 1 }) }, - } -}); -``` - -- [Layout Patterns Guide](./tempo.layout.md): Details on creating custom parsing patterns and using relative units. -- [Weekday Parsing Guide](./tempo.weekday.md): Deep dive into relative weekday expressions (e.g., "next Monday"). - -### US-Style Dates (Ambiguous Digits) -When parsing dates comprised entirely of digits (e.g., `04012026`), the input can be visually ambiguous: Is it `04-Jan-2026` (Day-Month-Year) or `Apr-01-2026` (Month-Day-Year)? - -Tempo solves this elegantly using **reasonable defaults and TimeZone awareness**: -1. **TimeZone Detection**: - Tempo will (if not provided) infer the timeZone from the runtime environment. - If you do provide a `timeZone` it should be an IANA timezone identifier or an "±hh:mm" offset. - If the timeZone is associated with one of the locales that suggest month-day-year order (which defaults to `en-US`), it assumes the input is US-style (Tempo.parse.mdyLocales) -2. **Prioritized Parsing**: - - If the timeZone favors US-style, Tempo tries the inbuilt **Month-Day-Year (`mdy`)** parsing layout *first*. - - If the timeZone favors the rest of the world, Tempo tries the inbuilt **Day-Month-Year (`dmy`)** parsing layout *first*. -3. **Automatic Fallback**: - If the first layout fails (for example, reading `15012026` as `mdy` fails because there is no 15th month), Tempo will automatically "re-try" using the alternate layout. - -You can configure what timeZone or specific layouts trigger this behavior in the configuration options: -```typescript -const usDate = new Tempo('04012026', { timeZone: 'America/New_York' }); // Parsed as Apr-01-2026 -const ukDate = new Tempo('04012026', { timeZone: 'Europe/London' }); // Parsed as 04-Jan-2026 -``` -*(Note: This logic only applies to **Parsing** digits-only input. **Formatting** US-style output remains dependent on the `{mm}{dd}{yyyy}` layout string you choose to output.)* - ---- - -## Formatting - -Formatting uses a placeholder syntax similar to many template engines: - -| Placeholder | Description | Type | Range | Example | -| :--- | :--- | :--- | :--- | :--- | -| `{yyyy}` | Year | `4-digit` | `0001-9999` | `2024` | -| `{yw}` | ISO Week-numbering Year | `4-digit` | `0001-9999` | `2024` | -| `{yy}` | Year | `2-digit` | `00-99` | `24` | -| `{mm}` | Month | `2-digit` | `01-12` | `05` | -| `{mon}` | Full Month Name | `string` | `January - December` | `June` | -| `{mmm}` | Month Name | `3-char string` | `Jan - Dec` | `Jun` | -| `{dd}` | Day of Month | `2-digit` | `01-31` | `20` | -| `{day}` | Day of Month | `1-2 digit` | `1-31` | `20` | -| `{wkd}` | Full Weekday Name | `string` | `Monday - Sunday` | `Monday` | -| `{www}` | Weekday Name | `3-char string` | `Mon - Sun` | `Mon` | -| `{dow}` | Day of Week (Mon-Sun) | `1-digit` | `1-7` | `1` | -| `{ww}` | Week of Year | `1-2 digit` | `1-53` | `21` | -| `{hh}` | Hour | `1-2 digit` | `0-24` | `14` | -| `{HH}` | Hour (with meridiem) | `1-2 digit` | `1-12` | `02pm` | -| `{mi}` | Minutes | `2-digit` | `00-59` | `05` | -| `{ss}` | Seconds | `2-digit` | `00-59` | `05` | -| `{ms}` | Milliseconds | `3-digit` | `000-999` | `005` | -| `{us}` | Microseconds | `3-digit` | `000-999` | `000` | -| `{ns}` | Nanoseconds | `3-digit` | `000-999` | `000` | -| `{ff}` | Fractional Seconds | `9-digit` | `0-999999999` | `005000000` | -| `{ts}` | Unix Timestamp | `digits` | `n/a` | `1716163200000` | -| `{#term}` | Unified Term Identity | `string` | `n/a` | `{#qtr}` or `{#quarter}` | - -Example: -```typescript -const t = new Tempo(); -t.format('{dd} {mon} {yyyy}'); // "24 January 2026" -``` - -### ISO 8601 Week Dates -*(Note: `{yw}` represents the ISO week year, which may differ from `{yyyy}` at the start or end of a calendar year if the current date belongs to an ISO week from the adjacent year.)* - -Tempo supports the **ISO 8601 Week Date** system, which is commonly used in business and logistics for unambiguous weekly scheduling. - -- **`{ww}`**: Represents the ISO week number (01–53). -- **`{yw}`**: Represents the ISO week-numbering year. - -A week in this system always starts on a **Monday**. Week 01 is defined as the week with the year's first Thursday (or the week containing January 4th). - -To format a standard ISO week date (e.g., `2024-W21`), use both placeholders together: -```typescript -const t = new Tempo('2024-05-20'); -t.format('{yw}-W{ww}'); // "2024-W21" -``` - ---- - -## Manipulation - -`Tempo` instances are **immutable**. Methods like `add` and `set` return a *new* `Tempo` instance. - -### `add(payload, options?)` -Adds a duration (positive or negative) or a date-time payload to the instance. -```typescript -t.add({ days: -1, hours: 2 }); -t.add('tomorrow'); -``` - -### `set(payload, options?)` -Sets the instance to a specific point or relative position. -```typescript -t.set({ hour: 0 }); // Midnight -t.set({ start: 'month' }); // Start of the current month -t.set({ end: '#quarter' }); // End of the current fiscal quarter -``` - -### `until(dateTime, unit?)` -Calculates the duration until another date-time. -```typescript -t.until('2024-12-25', 'days'); // Returns number of days -t.until('2024-12-25'); // Returns Temporal.Duration object, with an 'iso' property (ISO 8601 duration format) -``` - -### `since(dateTime, unit?)` -Calculates the elapsed time since another date-time (returns a human-readable string). -```typescript -t.since('yesterday', 'days'); // "1d ago" -t.add({days:-2}).set({period:'afternoon'}).since(); // Returns ISO 8601 duration format (e.g. 'P1DT20H59M49.290589287S') -``` - -### `compare(t1, t2)` -Static method which can be used to sort Tempo's across different timeZones -```typescript -const t1 = new Tempo('2024-05-20', { timeZone: 'America/New_York' }); -const t2 = new Tempo('2024-05-20', { timeZone: 'Europe/London' }); -[t1,t2].sort(Tempo.compare).forEach(t => console.log('timeZone: ', t.config.timeZone)); -// timeZone: Europe/London -// timeZone: America/New_York -``` -or to compare `Tempo` instances as "before" (-1), "same-as" (0), or "after" (1) -```typescript -const t1 = new Tempo('2024-05-20', { timeZone: 'America/New_York' }); -const t2 = new Tempo('2024-05-20', { timeZone: 'Europe/London' }); -Tempo.compare(t1, t2); // 1, meaning t1 is 'later than' t2 -``` - ---- - -## Plugin System - -Tempo is designed to be lean. Non-core features like the `ticker` or advanced business logic can be added via the plugin system. - -### Extending Tempo -To add a plugin, use the static `extend()` method. - -```typescript -import { Tempo } from '@magmacomputing/tempo/core'; -import { TickerModule } from '@magmacomputing/tempo/ticker'; - -Tempo.extend(TickerModule); -``` - -- [Plugin Development Guide](./tempo.plugin.md): A detailed overview of the `Tempo.extend()` API and best practices for creating custom extensions. - -> [!NOTE] -> **Selective Immobility**: When you extend Tempo, the base methods (like `format`, `add`, `set`) are protected. You can add NEW functionality, but you cannot overwrite the essential behavior of the library. - ---- - -## Ticker (Optional Plugin) - -`Tempo.ticker` creates a reactive stream of `Tempo` instances, making it easy to build clocks or countdowns. -**Note**: This requires the `TickerModule` to be specifically installed first, when using the 'Tempo Core' package. - -```typescript -// Pattern: Async Generator, which emits a new Tempo instance every second -for await (const t of Tempo.ticker()) { - console.log(t.format('{hh}:{mi}:{ss}')); -} -``` - -See the [Tempo Ticker guide](./tempo.ticker.md) for full details and API signatures. - ---- - -## Plugin (Terms) - -`Tempo` can be extended with 'terms' — plugins that calculate complex date ranges. - -### Unified Term Logic (v2.0.0) - -Terms are now fully integrated into the base `set()`, `add()`, `until()` and `format()` methods via the `#` indicator: - -1. **Anchored Mutations**: Jump to the `start`, `mid`, or `end` of any term: - ```typescript - t.set({ start: '#quarter' }); // April 1st (if in Q2) - t.set({ end: '#season' }); // Nov 30th (if in Autumn) - ``` -2. **Identity Placeholders**: Embed term identities into formatting strings: - - `{#qtr}`: Returns the technical key (e.g., `"Q2"`). - - `{#quarter}`: Returns the semantic label (e.g., `"Second Quarter"`), falling back to the key if no label exists. - -3. **Term Traversal (Math)**: Shift the date by semantic "steps" using `#term` units in `.add()`: - ```typescript - t.add({ '#quarter': 1 }); // Move to the same day/time in the NEXT quarter - t.add({ '#season': -1 }); // Move back one season - t.add({ '#morning': 1 }); // Jump across gaps to the next morning period - ``` - *Note: Relational math preserves your relative duration from the start of the term and applies overflow constraints.* - -### Persistent Access -Terms remain accessible via the `t.term` getter for programmatic use. The `start` and `end` boundaries are returned as **fluent, immutable Tempo instances**: -- `t.term.qtr`: Current fiscal calendar quarter object. -- `t.term.szn`: Current meteorological season (North/South hemisphere-aware). -- `t.term.zdc`: Current Western/Chinese astrological sign. - -```typescript -const q = t.term.qtr; -console.log(q.start.format('{dd} {mmm}')); // Fluent formatting directly from the term! -console.log(q.end.since(t, 'days')); // Calculate days remaining in the quarter -``` - -See the [Tempo Terms guide](./tempo.term.md) for full details and plugin development. - ---- - -## Context & Configuration - -Global settings can be configured using [`Tempo.init()`](./tempo.config.md). -This will affect any new Tempo instances created (but not affect any existing instances). - -```typescript -Tempo.init({ - timeZone: 'Europe/London', - locale: 'en-GB' -}); -``` - -### TIMEZONE Aliases -For convenience, `timeZone` configurations accept both strict IANA identifiers (e.g., `Australia/Sydney`) and common abbreviations. -Tempo will automatically translate these abbreviations before passing them to the underlying engine (e.g., `utc` -> `UTC`, `pst` -> `America/Los_Angeles`). - -These are stored in the `Tempo.TIMEZONE` registry, which is protected by **Soft Freeze** but remains extensible via `Tempo.registryUpdate()`. - -See the [Configuration Guide](./tempo.config.md#timezone-registry) for the complete list of default aliases. - -Instances can also be created with specific options: -```typescript -new Tempo('2024-05-20', { timeZone: 'AEST', debug: true }); -``` - ---- - -## Debugging - -Tempo includes a robust debugging mode that tracks internal mutations and parsing decisions. - -See the [Tempo Debugging guide](./tempo.debugging.md) for full details on using the `debug: true` flag and the `tempo.log` getter. - ---- - -## Technical References -- [API Reference](./tempo.api.md) -- [Cookbook](./tempo.cookbook.md) -- [Library Functionality](./tempo.library.md) -- [Architecture & Internal Protection](./architecture.md) diff --git a/packages/tempo/doc/architecture.md b/packages/tempo/doc/architecture.md index 7fbcb175..20f25506 100644 --- a/packages/tempo/doc/architecture.md +++ b/packages/tempo/doc/architecture.md @@ -19,7 +19,7 @@ The property descriptor is `enumerable: false, configurable: false, writable: fa **Benefits:** - **Reduced global footprint** — one slot instead of seven. - **Centralised hardening** — input validation (`addTerm`, `addPlugin`) and hook management (`setRegisterHook`, `fireRegisterHook`) live in one place. -- **Scoped runtimes (Experimental)** — `TempoRuntime.createScoped()` returns a fresh, isolated runtime that is *not* stored on `globalThis`, enabling clean test isolation without globalThis manipulation. **Note**: Scoped runtimes are currently an experimental internal feature and are not yet fully threaded through all core utilities. Scoped runtimes are not pinned to `globalThis`, lack the `defineProperty` descriptor protections of the primary instance, and instead rely solely on the lexical reference returned (contrasting with the hardened `getRuntime()` and `globalThis[BRIDGE]` behavior). Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). +- **Scoped runtimes (Experimental)** — `TempoRuntime.createScoped()` returns a fresh, isolated runtime for clean test isolation without `globalThis` manipulation. This remains an experimental internal feature and is not yet fully threaded through all core utilities. Unlike the primary runtime, a scoped runtime is not pinned to `globalThis`, does not receive the hardened `defineProperty` protections, and relies on the returned lexical reference instead of the shared `getRuntime()` / `globalThis[BRIDGE]` path. Implementation examples of this test-scoping pattern can be found in [plugin_registration.test.ts](../test/plugin_registration.test.ts) and [duration.core.test.ts](../test/duration.core.test.ts). - **Multi-bundle / HMR safety** — `getRuntime()` checks `globalThis[BRIDGE]` before constructing, so two bundle copies of Tempo always share the same runtime object, preserving the original split-brain guarantee. **User-facing "Global Discovery" slots remain on `globalThis`.** The `sym.$Tempo` slot (and custom discovery symbols passed to `Tempo.init`) are intentionally user-readable, so they stay as ordinary writable properties. Only internal bookkeeping moved into the runtime. @@ -96,8 +96,9 @@ The **Instance Shadowing** pattern is designed for massive scale. When a library - **Stage 2**: Tempo uses a **Generic Lazy Delegator** Proxy (via `getLazyDelegator`) which catches property access and evaluates it on-demand. - **Result**: The JS engine executes lookups via an optimized Proxy handler, making lookups nearly as fast as raw property access while keeping the state strictly immutable. -> [!TIP] -> For more implementation details, see [Lazy Evaluation Pattern](./lazy-evaluation-pattern.md). +::: tip +For more implementation details, see [Lazy Evaluation Pattern](./lazy-evaluation-pattern.md). +::: --- @@ -111,8 +112,9 @@ Global registries must be **live** but **secure**. As of **v2.0.1**, these are p - **The Library**: Uses a private symbol bypass to perform "Transactional Updates" via `registryUpdate()`. - **Result**: The object reference remains constant while allowing controlled extensibility. This ensures that internal caches (like the Master Guard) can be re-synchronized whenever a registry changes. -> [!TIP] -> For more implementation details, see [Soft Freeze Strategy](./soft_freeze_strategy.md). +::: tip +For more implementation details, see [Soft Freeze Strategy](./soft_freeze_strategy.md). +::: --- @@ -134,8 +136,9 @@ The efficiency of the Master Guard and the success of the Zero-Cost objective ha - **Instantiation Overhead**: ~523µs on average (passing the Master Guard). *(Node.js v24.14.1, 12th Gen Intel i7-1255U, Linux x86_64; steady-state measured after 1k warm-up runs, n=10k. Validates the Zero-Cost objective on this hardware.)* - **Fast-Fail Rejection**: ~359µs on average (failing the Master Guard). *(Node.js v24.14.1, 12th Gen Intel i7-1255U, Linux x86_64; steady-state measured after 1k warm-up runs, n=10k. Demonstrates the Master Guard's low-latency rejection performance.)* -> [!TIP] -> For detailed timing results and methodology, see [Performance Benchmarks](./tempo.benchmarks.md). +::: tip +For detailed timing results and methodology, see [Performance Benchmarks](./tempo.benchmarks.md). +::: --- diff --git a/packages/tempo/doc/commercial.md b/packages/tempo/doc/commercial.md index 15b86592..70f56d41 100644 --- a/packages/tempo/doc/commercial.md +++ b/packages/tempo/doc/commercial.md @@ -38,5 +38,6 @@ Ready to discuss your project? Get in touch with our engineering team: --- -> [!NOTE] -> Tempo is, and will always be, an **Open Core** project. Our professional services are designed to complement the free library with deep domain expertise and specialized extensions. +::: info +Tempo is, and will always be, an **Open Core** project. Our professional services are designed to complement the free library with deep domain expertise and specialized extensions. +::: diff --git a/packages/tempo/doc/installation.md b/packages/tempo/doc/installation.md index 318d1031..4c6c4982 100644 --- a/packages/tempo/doc/installation.md +++ b/packages/tempo/doc/installation.md @@ -1,6 +1,24 @@ # Installation Guide -Tempo is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, Tempo provides a specific path for you. +`Tempo` is designed to be environment-agnostic. Whether you are building a server-side application, a modern browser project with ESM, or a performance-critical "Lite" bundle, `Tempo` provides a specific path for you. + +## Temporal Polyfill Note + +`Tempo` expects the host environment to provide `Temporal`, either through native runtime support or a user-supplied polyfill. + +`Temporal` is now at Stage 4 and is expected to land broadly in runtimes soon. To avoid needlessly inflating package size with a dependency that will increasingly become unnecessary, `Tempo` does not bundle a `Temporal` polyfill by default. + +As of 13 January 2026, Chrome 144 has shipped `Temporal`, and Firefox 139 also includes native `Temporal` support, while Node.js still does not provide built-in `Temporal` globally. Please verify support in your actual target runtime(s) and add a polyfill only when needed. + +You can check at runtime with a simple guard: + +```js +if (typeof globalThis.Temporal === 'undefined') { + // Load your Temporal polyfill for this environment +} +``` + +Note: The examples below include a polyfill for demonstration purposes only, so the snippets work consistently across environments. --- @@ -21,6 +39,21 @@ import { Tempo } from '@magmacomputing/tempo'; const t = new Tempo('next Friday'); ``` +### Node.js quick-start (if Temporal is not available) + +The polyfill import shown here is conditional guidance, not required for all environments. + +```bash +npm install @js-temporal/polyfill +``` + +```javascript +import '@js-temporal/polyfill'; +import { Tempo } from '@magmacomputing/tempo'; + +const t = new Tempo('next Friday'); +``` + --- ## 🦕 Deno @@ -46,6 +79,8 @@ For browser environments that support **Import Maps**, you can use the granular ### 1. Import Map Setup Add this to your `` to resolve the dependencies: +> Note: If you are self-hosting Tempo files, use the shipped `packages/tempo/importmap.json` as-is for your installed version instead of hand-authoring `dist/` paths. + ```html diff --git a/packages/tempo/src/discrete/discrete.format.ts b/packages/tempo/src/discrete/discrete.format.ts index 3908e63f..21a145cb 100644 --- a/packages/tempo/src/discrete/discrete.format.ts +++ b/packages/tempo/src/discrete/discrete.format.ts @@ -1,11 +1,11 @@ import '#library/temporal.polyfill.js'; import { pad } from '#library/string.library.js'; import { ifNumeric } from '#library/coercion.library.js'; -import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined } from '#library/type.library.js'; +import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined } from '#library/assertion.library.js'; +import { delegator } from '#library/proxy.library.js'; -import { isTempo, enums, Match, getRuntime } from '#tempo/support'; +import { isTempo, enums, Match, getRuntime, NumericPattern } from '#tempo/support'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; -import { NumericPattern } from '../support/tempo.enum.js'; import type { Tempo } from '../tempo.class.js'; declare module '../tempo.class.js' { @@ -32,6 +32,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(Object.create(enums.FORMAT), config?.formats); const tz = config?.timeZone ?? 'UTC'; let zdt: any; @@ -59,32 +60,11 @@ export function format(obj?: Temporal.ZonedDateTime | any, fmt?: string | symbol zdt = obj; } - if (isUndefined(fmt)) { - const formats = config?.formats ?? enums.FORMAT; - return new Proxy({} as any, { - get(_, prop: string) { - if (!isString(prop) || prop === 'constructor' || prop === 'then') return undefined; - return format(zdt, prop); - }, - ownKeys() { - return Object.keys(formats); - }, - getOwnPropertyDescriptor(t, prop: string) { - if (isString(prop) && isDefined(formats[prop])) { - return { - enumerable: true, - configurable: true - }; - } - return undefined; - } - }); - } + if (isUndefined(fmt)) + return delegator(formats, (prop) => format(zdt, prop)); if (!isZonedDateTime(zdt)) return ''; - const formats = config?.formats ?? enums.FORMAT; - let template = (isString(fmt) && formats && (typeof (formats as any).has === 'function' ? (formats as any).has(fmt as string) : Object.prototype.hasOwnProperty.call(formats, fmt as string))) ? (formats as Record)[fmt as string] : String(fmt); diff --git a/packages/tempo/src/discrete/discrete.parse.ts b/packages/tempo/src/discrete/discrete.parse.ts index f828f658..30293e74 100644 --- a/packages/tempo/src/discrete/discrete.parse.ts +++ b/packages/tempo/src/discrete/discrete.parse.ts @@ -1,42 +1,23 @@ import '#library/temporal.polyfill.js'; -import { asType, isNull, isString, isObject, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty, type TypeValue } from '#library/type.library.js'; -import { asArray, asInteger, isNumeric } from '#library/coercion.library.js'; -import { instant } from '#library/temporal.library.js'; +import { asType } from '#library/type.library.js'; +import { isNull, isString, isObject, isFunction, isZonedDateTime, isInstant, isDefined, isUndefined, isIntegerLike, isEmpty } from '#library/assertion.library.js'; +import { asArray, asInteger } from '#library/coercion.library.js'; +import { isNumeric } from '#library/assertion.library.js'; +import { instant, getTemporalIds } from '#library/temporal.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; -import { markConfig } from '#library/symbol.library.js'; - -import { sym, enums, isTempo, Match, getRuntime } from '../support/support.index.js'; -import { init, extendState } from '../support/tempo.init.js'; -import { setPatterns } from '../support/tempo.util.js'; - -import { prefix, parseWeekday, parseDate, parseTime, parseZone } from '../plugin/module/module.lexer.js'; -import { resolveTermMutation, resolveTermValue } from '../plugin/module/module.term.js'; -import { compose } from '../plugin/module/module.composer.js'; +import type { TypeValue } from '#library/type.library.js'; +import { resolveTermMutation, resolveTermValue } from '../engine/engine.term.js'; +import { prefix, parseWeekday, parseDate, parseTime, parseZone } from '../engine/engine.lexer.js'; +import { compose } from '../engine/engine.composer.js'; import { getRange, getTermRange } from '../plugin/term.util.js'; import { defineInterpreterModule } from '../plugin/plugin.util.js'; import type { Range, ResolvedRange } from '../plugin/plugin.type.js'; -import type { Tempo } from '../tempo.class.js'; +import { sym, isTempo, TermError, getRuntime, Match } from '../support/support.index.js'; +import { markConfig, setPatterns, init, extendState } from '../support/support.index.js'; +import enums from '../support/tempo.enum.js'; import * as t from '../tempo.type.js'; - -/** - * Internal helpers to normalize TimeZone and Calendar IDs - */ -const tzId = (v: any): string => typeof v === 'string' ? v : (v?.id ?? v?.timeZoneId); -const calId = (v: any): string => typeof v === 'string' ? v : (v?.id ?? v?.calendarId); - -/** - * Internal helper to resolve state from 'this' context or first argument - */ -const withState = (fn: Function) => function (this: any, ...args: any[]) { - const isBound = isTempo(this); - const state = isBound ? (this as any)[sym.$Internal]() : args.shift(); - - if (!isBound && (!isObject(state) || !state?.config || !state?.parse)) - throw new TypeError(`[Tempo#_ParseEngine] Invalid state provided to withState() wrapper. Expected Tempo state object (with .config and .parse), but received: ${typeof state}. This often happens if the first argument is missing when calling standalone parse methods.`); - - return fn.call(this, state, ...args); -}; +import type { Tempo } from '../tempo.class.js'; /** * Internal Parse Engine Implementation @@ -51,8 +32,7 @@ const _ParseEngine = { if (!term && (isZonedDateTime(tempo) || isInstant(tempo))) { const { config } = state; - const tz = tzId(config.timeZone); - const cal = calId(config.calendar); + const [tz, cal] = getTemporalIds(config.timeZone, config.calendar); const dt = isZonedDateTime(tempo) ? tempo : (tempo as Temporal.Instant).toZonedDateTimeISO(tz); return dt.withTimeZone(tz).withCalendar(cal); } @@ -67,10 +47,9 @@ const _ParseEngine = { const val = dateTime ?? state.anchor ?? (isTempo(tempo) ? (tempo as any).toDateTime() : (isZonedDateTime(tempo) ? tempo : (isInstant(tempo) ? tempo.toZonedDateTimeISO(config.timeZone) : undefined))); const basis = isDefined(val) ? val : instant().toZonedDateTimeISO(config.timeZone); - const tz = isTempo(basis) ? (basis as any).tz : tzId(basis ?? config.timeZone); - const cal = isTempo(basis) ? (basis as any).cal : calId(basis ?? config.calendar); - - today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : instant().toZonedDateTimeISO(tz).withCalendar(cal)); + const [tz, cal] = isTempo(basis) ? [(basis as any).tz, (basis as any).cal] : getTemporalIds(basis ?? config.timeZone, basis ?? config.calendar); + const isAnchored = isDefined(val); + today = isZonedDateTime(basis) ? basis : (isTempo(basis) ? (basis as any).toDateTime() : (isZonedDateTime(val) ? val : instant().toZonedDateTimeISO(tz).withCalendar(cal))); const TempoClass = getRuntime().modules['Tempo']; const terms = getRuntime().pluginsDb.terms; @@ -80,7 +59,7 @@ const _ParseEngine = { const termObj = terms.find((termEntry: any) => termEntry.key === ident || termEntry.scope === ident); if (!termObj) { if (TempoClass) - (TempoClass as any)[sym.$termError](state.config, term); + (TempoClass as any)[TermError](state.config, term); return undefined as any; } @@ -129,19 +108,17 @@ const _ParseEngine = { throw new Error(msg); } if (terms.length === 0) { - if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[TermError](state.config, termKey); return undefined as any; } } } - const isAnchored = isDefined(dateTime) || isDefined(state.anchor); const resolvingKeys = new Set(); const res = _ParseEngine.conform(state, tempo, today, isAnchored, resolvingKeys); const { timeZone: tz2, calendar: cal2 } = state.config; - const targetTz = tzId(tz2); - const targetCal = calId(cal2); + const [targetTz, targetCal] = getTemporalIds(tz2, cal2); const { dateTime: dt, timeZone } = compose(res, today, tz, targetTz, targetCal); @@ -166,7 +143,7 @@ const _ParseEngine = { return (isZonedDateTime(dateTime) && !state.errored) ? dateTime : undefined as any; } finally { - if (isRoot) state.matches = undefined; + if (isRoot) delete state.matches; state.parseDepth--; } }, @@ -179,6 +156,7 @@ const _ParseEngine = { const terms = getRuntime().pluginsDb.terms; + if (isTempo(dateTime)) dateTime = dateTime.toDateTime(); if (!isZonedDateTime(dateTime)) { if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new TypeError(`Sacred Anchor corrupted: ${String(value)}`)); return arg; @@ -191,7 +169,7 @@ const _ParseEngine = { const termKey = Object.keys(options).find(k => k.startsWith('#')); if (termKey && terms.length === 0) { - if (TempoClass) (TempoClass as any)[sym.$termError](state.config, termKey); + if (TempoClass) (TempoClass as any)[TermError](state.config, termKey); return undefined as any; } @@ -245,7 +223,8 @@ const _ParseEngine = { value = trim; // Update value for downstream parsing } - return _ParseEngine.parseLayout(state, value as string | number, dateTime, isAnchored, resolvingKeys); + const res = _ParseEngine.parseLayout(state, value as string | number, dateTime, isAnchored, resolvingKeys); + return res; }, /** match a string or number against known layouts */ @@ -284,13 +263,13 @@ const _ParseEngine = { let zdt = dateTime as any; const anchorTime = zdt.toPlainTime(); + for (const [symKey, pat] of state.parse.pattern) { const groups = _ParseEngine.parseMatch(state, pat, trim); - if (isEmpty(groups)) continue; - - const hasAlias = Object.keys(groups).some(k => k.includes('evt') || k.includes('per')); - const isRootMatch = Object.keys(groups).some(k => k === 'dt' || k === 'tm'); - const hadEventOrPeriod = hasAlias || isRootMatch; + if (isEmpty(groups)) { + continue; + } + const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) || Object.values(groups).includes('now'); _ParseEngine.result(state, { match: symKey.description, value: trim, groups: { ...groups } }); @@ -301,22 +280,14 @@ const _ParseEngine = { dateTime = parseDate(groups, dateTime, (TempoClass as any)?.[sym.$dbg], state.config, state.parse["pivot"]); dateTime = parseTime(groups, dateTime); - const hasTime = Object.keys(groups).some(key => ['hh', 'mi', 'ss', 'ms', 'us', 'ns', 'ff', 'mer'].includes(key) || Match.period.test(key)) - || hadEventOrPeriod - || !dateTime.toPlainTime().equals(anchorTime); - - if (!isAnchored && !hasTime) + const isChanged = !dateTime.toPlainTime().equals(anchorTime); + 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 }); } - if (TempoClass) { - (TempoClass as any)[sym.$logDebug](state.config, 'groups', groups); - (TempoClass as any)[sym.$logDebug](state.config, 'pattern', symKey.description); - } - break; } @@ -335,10 +306,8 @@ const _ParseEngine = { }, /** resolve {event} | {period} to their date | time values (mutates groups) */ - parseGroups(state: any, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { - if (!isZonedDateTime(dateTime)) return dateTime; + parseGroups(state: t.Internal.State, groups: t.Groups, dateTime: Temporal.ZonedDateTime, isAnchored: boolean, resolvingKeys: Set): Temporal.ZonedDateTime { const TempoClass = getRuntime().modules['Tempo']; - const prevAnchor = state.anchor; const prevZdt = state.zdt; @@ -373,27 +342,34 @@ const _ParseEngine = { const isEvent = Match.event.test(key); const isGlobal = key.startsWith('g'); - const isLocal = key.startsWith('l'); - const idx = +key.substring((isGlobal || isLocal) ? 4 : 3); + const isNamed = key === 'gdt' || key === 'dt' || key === 'gtm' || key === 'tm'; + const idx = isNamed ? -1 : +(key.match(/\d+$/)?.[0] ?? -1); + + if (isNamed) { + resolved.add(key); + delete groups[key]; + continue; + } + const globalParse = isGlobal ? (TempoClass as any)?.[sym.$Internal]?.().parse : undefined; - const src = - isGlobal - ? (isEvent ? globalParse?.event : globalParse?.period) - : (isEvent ? state.parse.event : state.parse.period); + const src = isGlobal + ? (isEvent ? globalParse?.event : globalParse?.period) + : (isEvent ? state.parse.event : state.parse.period); const entry = ownEntries(src, true)[idx]; - if (!entry) { resolved.add(key); + delete groups[key]; continue; } - const aliasKey = `${key}:${String(entry[0])}`; + const aliasKey = entry[0] as string; if (resolvingKeys.size > 50 || resolvingKeys.has(aliasKey)) { const msg = `Infinite recursion detected in Tempo resolution for: ${aliasKey}`; state.errored = true; if (TempoClass) (TempoClass as any)[sym.$logError](state.config, new RangeError(msg)); resolved.add(key); + delete groups[key]; continue; } @@ -401,77 +377,82 @@ const _ParseEngine = { resolved.add(key); const definition = entry[1]; + const isFn = isFunction(definition); let res: string = ''; - if (typeof definition === 'function') { - try { - state.anchor = dateTime; - state.zdt = dateTime; - - // Provide a lightweight host context that mimics a Tempo instance for the handler - const host = { - add: (val: any) => dateTime.add(val), - set: (val: any) => isObject(val) ? dateTime.with(val) : dateTime, - toNow: () => Temporal.Now.zonedDateTimeISO(state.config.timeZone), - toDateTime: () => dateTime, - get hh() { return dateTime.hour }, - get mi() { return dateTime.minute }, - get ss() { return dateTime.second }, - get yy() { return dateTime.year }, - get mm() { return dateTime.month }, - get dd() { return dateTime.day }, - [sym.$isTempo]: true, - config: state.config - }; - - const result = (definition as Function).call(host); - if (isTempo(result)) dateTime = (result as any).toDateTime(); - else if (isZonedDateTime(result)) dateTime = result as Temporal.ZonedDateTime; - else if (isObject(result) && isFunction((result as any).toDateTime)) dateTime = (result as any).toDateTime(); - else dateTime = isZonedDateTime(state.zdt) ? (state.zdt as any) : dateTime; - res = String(result); - } catch (e: any) { - if (e.message.includes('Temporal')) { - res = (definition as any).toString(); - } else { - throw e; - } + if (isFn) { + // Provide a lightweight host context that mimics a Tempo instance for the handler + const host = { + add: (val: any) => { + return dateTime.add(val); + }, + subtract: (val: any) => { + return dateTime.subtract(val); + }, + with: (val: any) => dateTime.with(val), + set: (val: any, opt?: any) => { + const res = _ParseEngine.conform(state, val, dateTime, true, resolvingKeys); + return (TempoClass as any)?.from(isZonedDateTime(res.value) ? res.value : dateTime, { ...state.config, ...opt }); + }, + toNow: () => instant().toZonedDateTimeISO(state.config.timeZone).withCalendar(state.config.calendar), + toDateTime: () => dateTime, + get hh() { return dateTime.hour }, + get mi() { return dateTime.minute }, + get ss() { return dateTime.second }, + get yy() { return dateTime.year }, + get mm() { return dateTime.month }, + get dd() { return dateTime.day }, + [sym.$Identity]: true, + config: state.config + }; + + const result = (definition as Function).call(host); + if (isString(result) && /^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(result)) { + const [hourStr, minuteStr] = result.split(':'); + const hour = Number(hourStr); + const minute = Number(minuteStr); + dateTime = dateTime.with({ hour, minute, second: 0, millisecond: 0 }); + res = ''; + } else if (isTempo(result)) { + dateTime = (result as any).toDateTime(); + } else if (isZonedDateTime(result)) { + dateTime = result as Temporal.ZonedDateTime; + } else if (isObject(result) && isFunction((result as any).toDateTime)) { + dateTime = (result as any).toDateTime(); + } else { + res = isString(result) || isNumeric(result) ? String(result) : ''; } + state.zdt = dateTime; } else { res = (definition as string); } - if (isEvent && !isAnchored && isZonedDateTime(dateTime)) dateTime = (dateTime as any).startOfDay(); - - if (TempoClass) (TempoClass as any)[sym.$logDebug](state.config, 'event', `resolved "${key}" to "${res}" against ${(dateTime as any).toString?.() ?? String(dateTime)}`); - try { const type = isEvent ? 'Event' : 'Period'; - const val = entry![0]; const pat = (isEvent ? 'dt' : 'tm'); - const resolveVal = typeof definition === 'function' ? res : definition; - _ParseEngine.result(state, { type, value: val as any, match: pat, groups: { [key]: resolveVal as string } }); - - const resolving = new Set(resolvingKeys); - resolving.add(aliasKey); - const resMatch = _ParseEngine.parseLayout(state, res, dateTime, isAnchored, resolving); - - if (resMatch.type === 'Temporal.ZonedDateTime') - dateTime = resMatch.value; + const resolveVal = isFn ? res : definition; + const source = isGlobal ? 'global' : 'local'; + _ParseEngine.result(state, { type, value: entry[0] as any, match: pat, source, groups: { [key]: resolveVal as string } }); + + // Protect against recursive re-evaluation of same alias + if (!isEmpty(res) && res !== String(groups[key])) { + const resolving = new Set(resolvingKeys); + resolving.add(aliasKey); + const resMatch = _ParseEngine.parseLayout(state, res, dateTime, true, resolving); + + if (resMatch.type === 'Temporal.ZonedDateTime') + dateTime = resMatch.value; + } } finally { - resolved.add(key); + delete groups[key]; } - - delete groups[key]; } } finally { - state.anchor = prevAnchor; - if (state.parseDepth === 1) { - state.zdt = prevZdt; - state.matches = undefined; - } else { - if (isZonedDateTime(dateTime)) state.zdt = dateTime; - } + if (isDefined(prevAnchor)) state.anchor = prevAnchor; + else delete state.anchor; + if (isDefined(prevZdt)) state.zdt = prevZdt; + else delete state.zdt; state.parseDepth--; + if (state.parseDepth === 0) delete state.matches; } if (isDefined(groups["mm"]) && !isNumeric(groups["mm"])) { @@ -511,6 +492,18 @@ const _ParseEngine = { } }; +const withState = (fn: (state: t.Internal.State, ...args: A) => R) => { + return function (this: any, ...args: [t.Internal.State, ...A] | A): R { + const firstArg = args[0] as t.Internal.State | undefined; + if (isObject(firstArg) && isObject(firstArg.config) && isObject(firstArg.parse)) { + return fn(firstArg, ...(args.slice(1) as A)); + } + + const state = (this as any)?.[sym.$Internal]?.() ?? this; + return fn(state as t.Internal.State, ...(args as A)); + }; +}; + /** * Public Parse Engine (wrapped for dual-mode support) */ @@ -524,7 +517,7 @@ export const ParseEngine = { result: withState(_ParseEngine.result) }; -const isFunction = (v: any): v is Function => typeof v === 'function'; + /** * # ParseModule diff --git a/packages/tempo/src/plugin/module/module.composer.ts b/packages/tempo/src/engine/engine.composer.ts similarity index 94% rename from packages/tempo/src/plugin/module/module.composer.ts rename to packages/tempo/src/engine/engine.composer.ts index 62fce4c0..821751c2 100644 --- a/packages/tempo/src/plugin/module/module.composer.ts +++ b/packages/tempo/src/engine/engine.composer.ts @@ -1,6 +1,6 @@ -import { isNumeric } from '#library/coercion.library.js'; import { isTempo, Match } from '#tempo/support'; -import { TemporalObject, TypeValue, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/type.library.js'; +import { isNumeric, isInstant, isZonedDateTime, isPlainDate, isPlainDateTime } from '#library/assertion.library.js'; +import type { TemporalObject, TypeValue } from '#library/type.library.js'; import type { Tempo } from '#tempo/tempo.class.js'; /** diff --git a/packages/tempo/src/engine/engine.layout.ts b/packages/tempo/src/engine/engine.layout.ts new file mode 100644 index 00000000..eefa40a8 --- /dev/null +++ b/packages/tempo/src/engine/engine.layout.ts @@ -0,0 +1,101 @@ +import { ownEntries } from '#library/primitive.library.js'; +import type * as t from '../tempo.type.js'; + +export type LayoutEntry = [symbol, string]; +export type LayoutController = Record; + +export const DEFAULT_LAYOUT_CLASS: unique symbol = Symbol('default'); + +export interface ResolveLayoutOrderArgs { + layout: Record; + mdyLayouts: t.Pair[]; + isMonthDay: boolean; + layoutController?: LayoutController; + classification?: PropertyKey; +} + +/** + * Build the minimum controller map from the current layout order. + * This is the baseline framework entry until additional classifications are added. + */ +export function createLayoutController(layout: Record): LayoutController { + return { + [DEFAULT_LAYOUT_CLASS]: getLayoutOrder(layout), + }; +} + +/** + * Reorder layouts according to a classification entry. + * Unknown layout names are appended in their original relative order. + * + * @remarks + * Must return the original layout reference when no reordering occurs (no-op paths). + * This preserves referential equality for consumers expecting `resolvedLayout === originalLayout` + * when the classification is missing or causes no changes. + */ +export function resolveLayoutClassificationOrder(layout: Record, controller: LayoutController, classification: PropertyKey = DEFAULT_LAYOUT_CLASS): Record { + const preferred = controller[classification] ?? []; + if (preferred.length === 0) return layout; + + const entries = ownEntries(layout) as LayoutEntry[]; + const byName = new Map(entries.map(([key, value]) => [key.description ?? '', [key, value] as LayoutEntry])); + const next: LayoutEntry[] = []; + const seen = new Set(); + + preferred.forEach(name => { + const entry = byName.get(name); + if (!entry) return; + seen.add(entry[0]); + next.push(entry); + }); + + entries.forEach(entry => { + if (!seen.has(entry[0])) next.push(entry); + }); + + const changed = next.length === entries.length && next.some((entry, idx) => entry[0] !== entries[idx][0]); + return changed ? Object.fromEntries(next) as Record : layout; +} + +/** + * Resolve parse layout order based on locale preference while preserving + * existing pair-swap semantics used by Tempo. + * + * @remarks + * Must return the original layout reference when no reordering or swaps occur (no-op paths). + * This preserves referential equality for consumers expecting `resolvedLayout === originalLayout` + * when the locale preference matches existing order or no swap pairs are found. + */ +export function resolveLayoutOrder({ layout, mdyLayouts, isMonthDay, layoutController, classification }: ResolveLayoutOrderArgs): Record { + const ordered = resolveLayoutClassificationOrder( + layout, + layoutController ?? createLayoutController(layout), + classification ?? DEFAULT_LAYOUT_CLASS, + ); + + const layouts = ownEntries(ordered) as LayoutEntry[]; + let changed = false; + + mdyLayouts.forEach(([dmy, mdy]) => { + const idx1 = layouts.findIndex(([key]) => key.description === dmy); + const idx2 = layouts.findIndex(([key]) => key.description === mdy); + + if (idx1 === -1 || idx2 === -1) return; + + const swap1 = idx1 < idx2 && isMonthDay; + const swap2 = idx1 > idx2 && !isMonthDay; + if (swap1 || swap2) { + [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]]; + changed = true; + } + }); + + if (changed) return Object.fromEntries(layouts) as Record; + return ordered; +} + +/** return the current symbol-descriptions in parse order for debug diagnostics */ +export function getLayoutOrder(layout: Record): string[] { + return (ownEntries(layout) as LayoutEntry[]) + .map(([key]) => key.description ?? String(key)); +} diff --git a/packages/tempo/src/plugin/module/module.lexer.ts b/packages/tempo/src/engine/engine.lexer.ts similarity index 98% rename from packages/tempo/src/plugin/module/module.lexer.ts rename to packages/tempo/src/engine/engine.lexer.ts index ae21fa2a..5d0c4d04 100644 --- a/packages/tempo/src/plugin/module/module.lexer.ts +++ b/packages/tempo/src/engine/engine.lexer.ts @@ -1,10 +1,9 @@ import '#library/temporal.polyfill.js'; -import { isString, isEmpty, isUndefined, isDefined, isTemporal } from '#library/type.library.js'; +import { isString, isEmpty, isUndefined, isDefined, isTemporal } from '#library/assertion.library.js'; import { ownKeys, ownEntries } from '#library/primitive.library.js'; import { pad, singular } from '#library/string.library.js'; -import { Match } from '../../support/tempo.default.js'; -import enums from '../../support/tempo.enum.js'; -import * as t from '../../tempo.type.js'; +import { Match, enums } from '#tempo/support'; +import * as t from '../tempo.type.js'; /** * Internal Lexer helpers for the Tempo parsing engine. diff --git a/packages/tempo/src/plugin/module/module.term.ts b/packages/tempo/src/engine/engine.term.ts similarity index 93% rename from packages/tempo/src/plugin/module/module.term.ts rename to packages/tempo/src/engine/engine.term.ts index c32e71e9..cce7c84c 100644 --- a/packages/tempo/src/plugin/module/module.term.ts +++ b/packages/tempo/src/engine/engine.term.ts @@ -1,14 +1,14 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isString, isZonedDateTime } from '#library/type.library.js'; -import { asArray, isNumeric } from '#library/coercion.library.js'; +import { isDefined, isString, isZonedDateTime, isNumeric } from '#library/assertion.library.js'; +import { asArray } from '#library/coercion.library.js'; -import { sym, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; -import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../term.util.js'; -import { getHost } from '../plugin.util.js'; -import { parseModifier } from './module.lexer.js'; +import { TermError, getLargestUnit, SCHEMA, Match, isTempo } from '#tempo/support'; +import { getRange, getTermRange, resolveTermShift, findTermPlugin } from '../plugin/term.util.js'; +import { getHost } from '../plugin/plugin.util.js'; +import { parseModifier } from './engine.lexer.js'; -import type { Tempo } from '../../tempo.class.js'; -import type { TempoType } from '../plugin.type.js'; +import type { Tempo } from '../tempo.class.js'; +import type { TempoType } from '../plugin/plugin.type.js'; /** * Internal helper to safely get the ZonedDateTime from a Tempo instance or raw object @@ -36,7 +36,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const termObj = findTermPlugin(termPart); if (!termObj) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -81,7 +81,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const rawList = getRange(termObj, instance, zdt); const currentRange = getTermRange(instance, rawList, false, zdt) as any; if (!currentRange) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -118,7 +118,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (!target || remaining > 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -147,7 +147,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } candidates = rawList.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -191,7 +191,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (next) return next.start.withTimeZone(tz).withCalendar(cal); - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -209,7 +209,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (rKey) { const found = rawList.some(r => r.key?.toLowerCase() === rKey.toLowerCase()); if (!found) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } list = list.filter(r => r.key?.toLowerCase() === rKey.toLowerCase()); @@ -241,7 +241,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const candidates = resolved.filter(c => rKey ? c.key?.toLowerCase() === rKey.toLowerCase() : true); // prefer latest start <= cursor (zdt) const prev = candidates - .filter(it => (toZdt(it.start).epochNanoseconds) <= (zdt.epochNanoseconds )) + .filter(it => (toZdt(it.start).epochNanoseconds) <= (zdt.epochNanoseconds)) .sort((a, b) => { const sa = toZdt(a.start).epochNanoseconds; const sb = toZdt(b.start).epochNanoseconds; @@ -385,7 +385,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (remaining > 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -393,7 +393,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s if (mutate === 'mid' || mutate === 'end') { const finalRange = (getTermRange(instance, getRange(termObj, instance, jump), false, jump) as any); if (!finalRange) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } if (mutate === 'mid') { @@ -436,7 +436,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s let iterations = 0; while (next.epochNanoseconds <= zdt.epochNanoseconds) { if (++iterations > 50) { // Safety-Valve: prevent infinite look-ahead - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } else { const currentRange = termObj.define.call(new (getHost(instance))(jump, { ...instance.config, mode: 'strict' }), false); @@ -454,7 +454,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s const target = getTermRange(instance, rawList, Number(offset), zdt) as any; if (target) return toZdt(target.start).withTimeZone(tz).withCalendar(cal); - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -468,7 +468,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s let iterations = 0; while (remaining > 0) { if (++iterations > 100) { // Safety-Valve: prevent infinite shift - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -480,7 +480,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s } if (list.length === 0) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } @@ -492,7 +492,7 @@ export function resolveTermMutation(Tempo: TempoType, instance: Tempo, mutate: s // if we hit the edge of the current list, jump to the end of the current cycle and try again const current = (getTermRange(instance, list, false, jump) as any); if (!current) { - Tempo?.[sym.$termError]?.(instance.config, unit); + Tempo?.[TermError]?.(instance.config, unit); return null; } diff --git a/packages/tempo/src/plugin/module/module.duration.ts b/packages/tempo/src/module/module.duration.ts similarity index 91% rename from packages/tempo/src/plugin/module/module.duration.ts rename to packages/tempo/src/module/module.duration.ts index 9c0245a2..7fc3ad17 100644 --- a/packages/tempo/src/plugin/module/module.duration.ts +++ b/packages/tempo/src/module/module.duration.ts @@ -1,15 +1,15 @@ -import { isString, isObject, isDefined, isUndefined } from '#library/type.library.js'; +import { isString, isObject, isDefined, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; import { getAccessors } from '#library/reflection.library.js'; import { ifDefined } from '#library/object.library.js'; import { getRelativeTime } from '#library/international.library.js'; -import { defineInterpreterModule, interpret } from '../plugin.util.js'; -import enums from '../../support/tempo.enum.js'; -import type { Module } from '../plugin.type.js'; -import type { Tempo } from '../../tempo.class.js'; +import { defineInterpreterModule, interpret } from '../plugin/plugin.util.js'; +import { enums, isTempo } from '#tempo/support'; +import type { Module } from '../plugin/plugin.type.js'; +import type { Tempo } from '../tempo.class.js'; -declare module '../../tempo.class.js' { +declare module '../tempo.class.js' { namespace Tempo { /** returns a full Tempo Duration object (EDO) for the given input */ function duration(input: any): Tempo.Duration; @@ -67,9 +67,10 @@ function duration(this: Tempo, type: 'until' | 'since', arg?: any, until?: any) ({ unit, ...opts } = until as any) else unit = until; break; - case isObject(arg) && isString(until): - unit = until; - ({ value, ...opts } = arg as any); + case isObject(arg) && isTempo(arg): + value = (arg as any).toDateTime(); + if (isObject(until)) ({ unit, ...opts } = until as any); + else unit = until; break; case isObject(arg) && isObject(until): ({ value, unit, ...opts } = Object.assign({ value: arg }, until) as any); diff --git a/packages/tempo/src/plugin/module/module.mutate.ts b/packages/tempo/src/module/module.mutate.ts similarity index 95% rename from packages/tempo/src/plugin/module/module.mutate.ts rename to packages/tempo/src/module/module.mutate.ts index 64cfdca6..c3ec211e 100644 --- a/packages/tempo/src/plugin/module/module.mutate.ts +++ b/packages/tempo/src/module/module.mutate.ts @@ -1,13 +1,12 @@ -import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/type.library.js'; +import { isDefined, isObject, isString, isUndefined, isZonedDateTime } from '#library/assertion.library.js'; import { singular } from '#library/string.library.js'; -import { sym } from '../../support/tempo.symbol.js'; -import enums from '../../support/tempo.enum.js'; -import { defineInterpreterModule } from '../plugin.util.js'; -import { findTermPlugin } from '../term.util.js'; -import { resolveTermMutation } from './module.term.js'; -import type { Tempo } from '../../tempo.class.js'; -import type * as t from '../../tempo.type.js'; +import { sym, enums } from '#tempo/support'; +import { defineInterpreterModule } from '../plugin/plugin.util.js'; +import { findTermPlugin } from '../plugin/term.util.js'; +import { resolveTermMutation } from '../engine/engine.term.js'; +import type { Tempo } from '../tempo.class.js'; +import type * as t from '../tempo.type.js'; declare module '#library/type.library.js' { interface TypeValueMap { diff --git a/packages/tempo/src/plugin/extend/extend.ticker.ts b/packages/tempo/src/plugin/extend/extend.ticker.ts index 67325004..be49067b 100644 --- a/packages/tempo/src/plugin/extend/extend.ticker.ts +++ b/packages/tempo/src/plugin/extend/extend.ticker.ts @@ -1,12 +1,10 @@ -import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber } from '#library/type.library.js' -import { Pledge } from '#library/pledge.class.js' -import { asArray, isNumeric } from '#library/coercion.library.js' -import { instant, normaliseFractionalDurations } from '#library/temporal.library.js' -import { markConfig } from '#library/symbol.library.js' +import { isObject, isFunction, isDefined, isUndefined, isEmpty, isNumber, isNumeric, isFiniteNumber } from '#library/assertion.library.js'; +import { Pledge } from '#library/pledge.class.js'; +import { asArray } from '#library/coercion.library.js'; +import { instant, normaliseFractionalDurations } from '#library/temporal.library.js'; -import { DURATIONS } from '../../support/tempo.enum.js' +import { sym, markConfig, enums } from '#tempo/support'; import { defineExtension, attachStatics } from '../plugin.util.js' -import { sym } from '../../support/tempo.symbol.js'; import type { Tempo } from '../../tempo.class.js' import type { Extension, TempoType } from '../plugin.type.js' @@ -140,8 +138,8 @@ class TickerInstance implements Ticker.Descriptor { } // ── Initialization ─────────────────────────────────────────────────── - const isSeed = isDefined(rawOptions.seed) && (!isNumber(rawOptions.seed) || (Number.isFinite(rawOptions.seed as number) && !Number.isNaN(rawOptions.seed as number))); - const isInterval = isDefined(rawOptions.seconds) && Number.isFinite(rawOptions.seconds) && !Number.isNaN(rawOptions.seconds); + const isSeed = isDefined(rawOptions.seed); + const isInterval = isDefined(rawOptions.seconds) && isFiniteNumber(rawOptions.seconds); if (isDefined(arg1) && !isInterval && !isSeed && !cb) { (this.#TempoClass as any)[sym.$logError](markConfig(rawOptions), `Invalid Ticker interval or seed: ${String(arg1)}`); @@ -152,7 +150,7 @@ class TickerInstance implements Ticker.Descriptor { this.#until = stopAt ? new this.#TempoClass(isOptions(stopAt) ? undefined : stopAt, isOptions(stopAt) ? { ...rest, ...stopAt } : rest) : undefined; if (cb) this.#listeners.add(cb); - const durationKeys = new Set(Object.keys(DURATIONS)); + const durationKeys = new Set(Object.keys(enums.DURATIONS)); for (const [key, val] of Object.entries(rest)) if (isDefined(val) && (durationKeys.has(key) || key.startsWith('#'))) this.#payload[key] = val; @@ -364,10 +362,10 @@ export const TickerModule: Extension = defineExtension({ if (prop === 'pulse') return instance.pulse.bind(instance); if (prop === 'on') return instance.on.bind(instance); if (prop === 'stop') return instance.stop.bind(instance); - if (prop === 'info') return instance.info; if (prop === 'next') return instance.next.bind(instance); if (prop === 'return') return instance.return.bind(instance); if (prop === 'throw') return instance.throw.bind(instance); + if (prop === 'info') return instance.info; if (prop === Symbol.asyncIterator) return () => proxy; if (prop === Symbol.asyncDispose) return instance[Symbol.asyncDispose].bind(instance); if (prop === Symbol.dispose) return instance[Symbol.dispose].bind(instance); diff --git a/packages/tempo/src/plugin/plugin.type.ts b/packages/tempo/src/plugin/plugin.type.ts index 8bfd4979..094ac76f 100644 --- a/packages/tempo/src/plugin/plugin.type.ts +++ b/packages/tempo/src/plugin/plugin.type.ts @@ -1,6 +1,6 @@ import type { Prettify, Property } from '#library/type.library.js'; import type { Tempo } from '../tempo.class.js'; -import { TermError } from '../support/tempo.symbol.js'; +import { TermError } from '#tempo/support'; export type TempoType = typeof Tempo & { [TermError]?: (config: any, term: string) => void; diff --git a/packages/tempo/src/plugin/plugin.util.ts b/packages/tempo/src/plugin/plugin.util.ts index 3f4d1e36..81489024 100644 --- a/packages/tempo/src/plugin/plugin.util.ts +++ b/packages/tempo/src/plugin/plugin.util.ts @@ -1,4 +1,4 @@ -import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/type.library.js'; +import { isFunction, isString, isUndefined, isClass, isObject, isDefined } from '#library/assertion.library.js'; import { secureRef } from '#library/proxy.library.js'; import { sym, getRuntime, isTempo } from '#tempo/support'; @@ -100,7 +100,7 @@ export function attachStatics(TempoClass: any, props: Record) { // use catch:true to report the collision without a fatal throw (supports re-extension in shared environments) TempoClass[sym.$logError]({ ...TempoClass.config, catch: true }, msg); } - console.error(msg); + // console.error(msg); continue; } diff --git a/packages/tempo/src/plugin/term.util.ts b/packages/tempo/src/plugin/term.util.ts index a3c319fe..cccfffe4 100644 --- a/packages/tempo/src/plugin/term.util.ts +++ b/packages/tempo/src/plugin/term.util.ts @@ -1,8 +1,8 @@ import { toZonedDateTime, toInstant } from '#library/temporal.library.js'; -import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/type.library.js'; -import { secure } from '#library/utility.library.js'; +import { isDefined, isFunction, isString, isUndefined, isNumber } from '#library/assertion.library.js'; +import { secure } from '#library/proxy.library.js'; import { sortKey, byKey } from '#library/array.library.js'; -import { sym, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; +import { sym, TermError, SCHEMA, getLargestUnit, isTempo, getRuntime } from '#tempo/support'; import type { Tempo } from '../tempo.class.js'; import type { TermPlugin, Range, ResolvedRange } from './plugin.type.js'; import { getHost } from './plugin.util.js'; @@ -161,7 +161,15 @@ export function getRange(entry: any, t: Tempo, anchor?: any, group?: string): Ra const keys = (term as any).groupBy ?? []; if (keys.length > 0) { - list = list.filter(r => keys.every((key: string) => r[key] === (t.config as any)[key])); + list = list.filter(r => keys.every((key: string) => { + if (key === 'sphere') { + const valA = String(r[key] ?? '').toLowerCase(); + const valB = String((t.config as any)[key] ?? '').toLowerCase(); + if (valA === '' || valB === '') return false; + return valB.includes(valA); + } + return r[key] === (t.config as any)[key]; + })); } if (group) { @@ -264,7 +272,7 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco // 1. Resolve Template (supporting optional dynamic grouping) let list: Range[] = []; if (!isDefined(template)) { - (t.constructor as any)[sym.$termError](t.config, 'template'); + (t.constructor as any)[TermError]?.(t.config, 'template'); return []; } @@ -274,11 +282,43 @@ export function resolveCycleWindow(source: Tempo | any, template: Range[] | Reco .join('.'); list = (template as any)[groupKey] ?? []; + if (list.length === 0 && groupBy.includes('sphere')) { + const sphereIdx = groupBy.indexOf('sphere'); + const targetParts = groupKey.split('.'); + const targetSphere = (targetParts.length > sphereIdx ? targetParts[sphereIdx] : '').toLowerCase(); + + let bestKey: string | undefined; + let bestSphereLength = -1; + + for (const k of Object.keys(template)) { + const kParts = k.split('.'); + if (kParts.length !== targetParts.length) continue; + + const sphereSegment = (kParts[sphereIdx] ?? '').trim(); + if (!sphereSegment) continue; + + const staticMatch = kParts.every((p, i) => i === sphereIdx || p === targetParts[i]); + if (!staticMatch) continue; + + const sphereLower = sphereSegment.toLowerCase(); + const sphereMatch = targetSphere.includes(sphereLower); + if (!sphereMatch) continue; + + if ( + sphereSegment.length > bestSphereLength || + (sphereSegment.length === bestSphereLength && (!bestKey || k.localeCompare(bestKey) < 0)) + ) { + bestKey = k; + bestSphereLength = sphereSegment.length; + } + } + if (bestKey) list = (template as any)[bestKey]; + } if (list.length === 0) { const missing = groupBy.filter(k => isUndefined(options[k]) && isUndefined(anchor?.[k]) && isUndefined(t.config[k])); const msg = missing.length > 0 ? `Missing grouping criteria: ${missing.join(', ')}` : `No ranges found for group: ${groupKey}`; - (t.constructor as any)[sym.$termError](t.config, msg); + (t.constructor as any)[TermError]?.(t.config, msg); return []; } } else { diff --git a/packages/tempo/src/plugin/term/term.quarter.ts b/packages/tempo/src/plugin/term/term.quarter.ts index 20e6d561..90901670 100644 --- a/packages/tempo/src/plugin/term/term.quarter.ts +++ b/packages/tempo/src/plugin/term/term.quarter.ts @@ -1,8 +1,8 @@ import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; import { COMPASS } from '../../support/tempo.enum.js'; -import { type Tempo } from '../../tempo.class.js'; -import { isNumber } from '#library/type.library.js'; +import { isNumber } from '#library/assertion.library.js'; import { asArray } from '#library'; +import type { Tempo } from '../../tempo.class.js'; /** definition of fiscal quarter ranges */ const groups = defineRange([ diff --git a/packages/tempo/src/plugin/term/term.zodiac.ts b/packages/tempo/src/plugin/term/term.zodiac.ts index b5f2d098..1ce0ad2b 100644 --- a/packages/tempo/src/plugin/term/term.zodiac.ts +++ b/packages/tempo/src/plugin/term/term.zodiac.ts @@ -1,6 +1,6 @@ import { defineTerm, getTermRange, defineRange, resolveCycleWindow } from '../term.util.js'; -import { type Tempo } from '../../tempo.class.js'; -import { isNumber } from '#library/type.library.js'; +import { isNumber } from '#library/assertion.library.js'; +import type { Tempo } from '../../tempo.class.js'; /** definition of astrological zodiac ranges */ const groups = defineRange([ diff --git a/packages/tempo/src/support/support.index.ts b/packages/tempo/src/support/support.index.ts index 86c54f48..83cf6d76 100644 --- a/packages/tempo/src/support/support.index.ts +++ b/packages/tempo/src/support/support.index.ts @@ -25,11 +25,11 @@ export { NumericPattern } from './tempo.enum.js'; +export { markConfig } from '#library/symbol.library.js'; export { sym, isTempo, Token, TermError, type TempoBrand } from './tempo.symbol.js'; +export { $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, $setEvents, $setPeriods, $buildGuard, $IsBase, $Identity, $Logify, $Discover } from './tempo.symbol.js'; export { registryUpdate, registryReset, onRegistryReset } from './tempo.register.js'; export { getRuntime, TempoRuntime } from './tempo.runtime.js'; export { Match, Snippet, Layout, Event, Period, Ignore, Guard, Default } from './tempo.default.js'; -export { SCHEMA, getLargestUnit } from './tempo.util.js'; -export { init, extendState } from './tempo.init.js'; - -export { default as lib } from '#library/symbol.library.js'; +export { SCHEMA, getLargestUnit, setPatterns } from './tempo.util.js'; +export { init, extendState } from './tempo.init.js'; \ No newline at end of file diff --git a/packages/tempo/src/support/tempo.default.ts b/packages/tempo/src/support/tempo.default.ts index 19090343..a68805b9 100644 --- a/packages/tempo/src/support/tempo.default.ts +++ b/packages/tempo/src/support/tempo.default.ts @@ -1,6 +1,5 @@ import { looseIndex } from '#library/object.library.js'; -import { secure } from '#library/utility.library.js'; -import { proxify } from '#library/proxy.library.js'; +import { secure, proxify } from '#library/proxy.library.js'; import { getDateTimeFormat } from '#library/international.library.js'; import { NUMBER, MODE } from './tempo.enum.js'; @@ -22,14 +21,13 @@ export const Match = proxify({ /** two digit year */ twoDigit: /^[0-9]{2}$/, /** date */ date: /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, /** time */ time: /^[0-9]{2}:[0-9]{2}(:[0-9]{2})?$/, - /** hour-minute-second with no separator */ hhmiss: /(hh)(m[i|m])(ss)?/i, /** separator characters (/ - . , T) */ separator: /[T\/\-\.\s,]/, /** modifier characters (+-<>=) */ modifier: /[\+\-\<\>][\=]?|this|next|prev|last/, /** offset post keywords (ago|hence) */ affix: /ago|hence|from now/, /** strip out these characters from a string */ strips: /\(|\)/g, /** whitespace characters */ spaces: /\s+/g, /** Z character */ zed: /^Z$/, - /** base guard characters (digits and common symbols) */ guard: /[\d\s\-\.\:T\/Z\+\-\(\)\,\=\#]/i, + /** base guard characters (digits and common symbols) */ guard: /[\d\s\-\.\:T\/Z\+\-\(\)\,\=\#\<\>]/i, /** bracketed content (timezone/calendar) */ bracket: /\[[^\]]+\]/i, /** slick shorthand-shifter (e.g. #qtr.>2q2) */ shorthand: /(?:(?:#[\w]+|[\w]+)\.(?:[\+\-\<\>]=?|next|prev|this|last)?(?:[0-9]+)?(?:[\w]*))/, /** anchored version for shifter resolution */ slick: /^(?#[\w]+|[\w]+)\.(?[\+\-\<\>]=?|next|prev|this|last)?(?[0-9]+)?(?[\w]*)$/, @@ -53,24 +51,24 @@ export const Match = proxify({ // Note: computed Components ('evt', 'per') are added during 'Tempo.init()' (for static) and/or 'new Tempo()' (per instance) /** @internal Tempo Snippet registry */ export const Snippet = looseIndex()({ - [Token.yy]: /(?([0-9]{2})?[0-9]{2})/, // arbitrary upper-limit of yy=9999 - [Token.mm]: /(?[0\s]?[1-9]|1[0-2]|Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)/, // month-name (abbrev or full) or month-number 01-12 - [Token.dd]: /(?
[0\s]?[1-9]|[12][0-9]|3[01])(?:\s?(?:st|nd|rd|th))?/, // day-number 01-31 - [Token.hh]: /(?2[0-4]|[01]?[0-9])/, // hour-number 00-24 + [Token.yy]: /(?[0-9]{2}(?:[0-9]{2})?)/, // year must be exactly 2 or 4 digits + [Token.mm]: /(?[0 ]?[1-9]|1[0-2]|Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)/, // month-name (abbrev or full) or month-number 01-12; leading '0' or space only (not \s — tab/newline are not valid padding) + [Token.dd]: /(?
[0 ]?[1-9]|[12][0-9]|3[01])(?:\s?(?:st|nd|rd|th))?/, // day-number 01-31; leading '0' or space only (not \s — tab/newline are not valid padding) + [Token.hh]: /(?2[0-4]|[01]?[0-9])/, // hour 00-24; CAUTION: in non-anchored use '25' partially matches as '2' via [01]?[0-9] — always use within anchored layouts; single-digit hours (e.g. '9') are intentionally supported [Token.mi]: /(\:(?[0-5][0-9]))/, // minute-number 00-59 [Token.ss]: /(\:(?[0-5][0-9]))/, // seconds-number 00-59 [Token.ff]: /(\.(?[0-9]{1,9}))/, // fractional-seconds up-to 9-digits [Token.mer]: /(\s*(?am|pm))/, // meridiem suffix (am,pm) - [Token.sfx]: /((?:{sep}+|T)({tm}){tzd}?)/, // time-pattern suffix 'T {tm} Z' + [Token.sfx]: /((?:{sep}+|T)({tm}){tzd}?)/, // time-pattern suffix 'T {tm} Z'; NOTE: {tm} resolves via Layout fallback in compileRegExp (cross-registry dependency: Snippet → Layout) [Token.wkd]: /(?Mon(?:day)?|Tue(?:sday)?|Wed(?:nesday)?|Thu(?:rsday)?|Fri(?:day)?|Sat(?:urday)?|Sun(?:day)?)/, // day-name (abbrev or full) - [Token.tzd]: /(?Z|(?:\+(?:(?:0[0-9]|1[0-3]):?[0-5][0-9]|14:00)|-(?:(?:0[0-9]|1[0-1]):?[0-5][0-9]|12:00)))/, // time-zone offset +14:00 to -12:00 - [Token.nbr]: new RegExp(`(?[0-9]+|${Object.keys(NUMBER).join('|')})`), // modifier count + [Token.tzd]: /(?Z|(?:\+(?:(?:0[0-9]|1[0-3]):?[0-5][0-9]|14:?00)|-(?:(?:0[0-9]|1[0-1]):?[0-5][0-9]|12:?00)))/, // time-zone offset +14:00 to -12:00; colon optional throughout (including boundary values UTC+14 and UTC-12) + [Token.nbr]: new RegExp(`(?[0-9]+|${Object.keys(NUMBER).map(w => Match.escape(w)).join('|')})`), // modifier count; number-word keys are regex-escaped at construction time (setPatterns() also re-escapes, but defence-in-depth) [Token.afx]: new RegExp(`((s)? (?${Match.affix.source}))?{sep}?`), // affix optional plural 's' and (ago|hence) - [Token.mod]: new RegExp(`((?${Match.modifier.source})?{nbr}? *)`), // modifier (+,-,<,<=,>,>=) plus optional offset-count + [Token.mod]: new RegExp(`((?${Match.modifier.source})?{nbr}? *)`), // modifier (+,-,<,<=,>,>=) plus optional count; CAUTION: all sub-components are optional so this snippet always matches the empty string — standard layouts all use {mod}? to reflect this [Token.sep]: new RegExp(`(?:${Match.separator.source})`), // date-input separator character "/\\-., " (non-capture group) [Token.unt]: /(?year|month|week|day|hour|minute|second|millisecond)(?:s)?/, // useful for '2 days ago' etc [Token.brk]: new RegExp(`(\\[(?${bracket_content.source})\\](?:\\[(?${bracket_content.source})\\])?)?`), // timezone/calendar brackets [...] - [Token.slk]: new RegExp(`${Match.shorthand.source}`), // shorthand shifter + [Token.slk]: new RegExp(`${Match.shorthand.source}`), // shorthand shifter }) /** @internal Tempo Snippet type */ export type Snippet = typeof Snippet @@ -81,13 +79,18 @@ export type Snippet = typeof Snippet */ /** @internal Tempo Layout registry */ export const Layout = looseIndex()({ - [Token.dt]: '({dd}{sep}?{mm}({sep}?{yy})?|{mod}?({evt})|(?{slk}))',// calendar, event or slick + [Token.hms]: '(?(?:[01][0-9]|2[0-4]))(?[0-5][0-9])(?[0-5][0-9])', // compact clock (hhmiss) + [Token.dmy6]: '(?
0[1-9]|[12][0-9]|3[01])(?0[1-9]|1[0-2])(?[0-9]{2})',// compact date (ddmmyy) + [Token.mdy6]: '(?0[1-9]|1[0-2])(?
0[1-9]|[12][0-9]|3[01])(?[0-9]{2})',// compact date (mmddyy) + [Token.ymd6]: '(?[0-9]{2})(?0[1-9]|1[0-2])(?
0[1-9]|[12][0-9]|3[01])',// compact date (yymmdd) + [Token.wkd]: '{mod}?{wkd}{afx}?{sfx}?', // weekday-only layout; MUST precede {dt} (which also matches bare weekday names via its {wkd} alternative) + [Token.dt]: '({dd}{sep}?{mm}({sep}?{yy})?|{mod}?({evt})|(?{slk})|{wkd})',// calendar, event, slick or weekday [Token.tm]: '({hh}{mi}?{ss}?{ff}?{mer}?|{per})', // clock or period [Token.dtm]: '({dt})(?:(?:{sep}+|T)({tm}))?{tzd}?{brk}?', // calendar/event and clock/period - [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) - [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) + [Token.tmd]: '({tm})(?:(?:{sep}+|T)({dt}))?{tzd}?{brk}?', // clock/period and calendar/event [Token.ymd]: '({wkd}{sep}+)?{yy}{sep}?{mm}({sep}?{dd})?{sfx}?{brk}?',// year-month(-day) - [Token.wkd]: '{mod}?{wkd}{afx}?{sfx}?', // special layout (no {dt}!) used for weekday calcs (only one that requires {wkd} pattern) + [Token.mdy]: '({wkd}{sep}+)?{mm}{sep}?{dd}({sep}?{yy})?{sfx}?{brk}?',// month-day(-year) + [Token.dmy]: '({wkd}{sep}+)?{dd}{sep}?{mm}({sep}?{yy})?{sfx}?{brk}?',// day-month(-year) [Token.off]: '{mod}?{dd}{afx}?', // day of month, with optional offset [Token.rel]: '{nbr}{sep}?{unt}{sep}?{afx}', // relative duration (e.g. 2 days ago) }) @@ -96,9 +99,10 @@ export type Layout = typeof Layout /** * an {event} is a Record of regex-pattern-like-string keys that describe Date strings. - * values can be a string or a function that returns a string. + * values can be a string, or a function that resolves to a date-like value. * if assigning a function, use standard 'function()' syntax to allow for 'this' binding. - * also, a function should always have a .toString() method which returns a parse-able Date string + * Event functions should resolve to the date side of parsing (for example a parse-able date string, + * a ZonedDateTime, or a Tempo instance whose date component is meaningful to the caller). */ /** @internal Tempo Event registry */ export const Event = looseIndex()({ @@ -124,14 +128,20 @@ export const Event = looseIndex()({ // RELATIVE: Offsets the current anchor by one day return this.add({ days: -1 }); }, + 'fortnight': function (this: Tempo) { + // RELATIVE: Offsets the current anchor by two weeks + return this.add({ weeks: 2 }); + }, }); /** @internal Tempo Event type */ export type Event = typeof Event /** * a {period} is a Record of regex-pattern-like keys that describe pre-defined Time strings. - * values can be a string or a function that returns a string. + * values can be a string, or a function that resolves to a time-like value. * if using a function, use regular 'function()' syntax to allow for 'this' binding. + * Period functions should resolve to the time side of parsing (ideally a parse-able clock value, + * or a Tempo/ZonedDateTime whose time component is meaningful to the caller). */ /** @internal Tempo Period registry */ export const Period = looseIndex()({ @@ -143,6 +153,9 @@ export const Period = looseIndex()({ 'after[ -]?noon': '3:00pm', 'evening': '18:00', 'night': '20:00', + 'half[ -]?hour': function (this: Tempo) { + return `${this.hh}:30`; + }, }) /** @internal Tempo Period type */ export type Period = typeof Period @@ -175,6 +188,6 @@ export const Default = secure({ /** default timezone if not specified */ timeZone: getDateTimeFormat().timeZone, /** default locale if not specified */ locale: getDateTimeFormat().locale, /** locales that prefer month-day order */ mdyLocales: ['en-US', 'en-AS'], /** @link https: //en.wikipedia.org/wiki/Date_format_by_country */ - /** layouts that need to swap parse-order */ mdyLayouts: [['dayMonthYear', 'monthDayYear']], + /** layouts that need to swap parse-order */ mdyLayouts: [['dayMonthYearShort', 'monthDayYearShort'], ['dayMonthYear', 'monthDayYear']], /** hemisphere for term.qtr or term.szn */ sphere: undefined, } as Options) diff --git a/packages/tempo/src/support/tempo.enum.ts b/packages/tempo/src/support/tempo.enum.ts index 40ca4530..ad5ae1dc 100644 --- a/packages/tempo/src/support/tempo.enum.ts +++ b/packages/tempo/src/support/tempo.enum.ts @@ -1,4 +1,4 @@ -import lib from '#library/symbol.library.js'; +import { sym } from './tempo.symbol.js'; import { enumify, Enum } from '#library/enumerate.library.js'; import { proxify } from '#library/proxy.library.js'; import { allDescriptors } from '#library/reflection.library.js'; @@ -15,12 +15,12 @@ export const SEASON = enumify({ export type SEASON = ValueOf /** cardinal directions */ -export const COMPASS = enumify({ +export const COMPASS = looseIndex()(enumify({ North: 'north', South: 'south', East: 'east', West: 'west' -}, false); +}, false)); export type COMPASS = ValueOf /** @@ -104,13 +104,13 @@ export const STATE = { DURATIONS: allDescriptors(DEFAULTS.DURATIONS), FORMAT: allDescriptors(DEFAULTS.FORMAT), LIMIT: allDescriptors(DEFAULTS.LIMIT), -} as const; +}; -(STATE.NUMBER as any)[lib.$Extensible] = true; -(STATE.FORMAT as any)[lib.$Extensible] = true; -(STATE.TIMEZONE as any)[lib.$Extensible] = true; -(STATE.DURATION as any)[lib.$Extensible] = true; -(STATE.DURATIONS as any)[lib.$Extensible] = true; +(STATE.NUMBER as any)[sym.$Extensible] = true; +(STATE.FORMAT as any)[sym.$Extensible] = true; +(STATE.TIMEZONE as any)[sym.$Extensible] = true; +(STATE.DURATION as any)[sym.$Extensible] = true; +(STATE.DURATIONS as any)[sym.$Extensible] = true; /** Gregorian calendar week-days (short-form) */ export const WEEKDAY = enumify(['All', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); @@ -133,7 +133,7 @@ export type MONTHS = KeyOf export type Months = ValueOf /** number names (0-10) */ -export const NUMBER = looseIndex()(proxify(enumify(STATE.NUMBER, false), true, false)); +export const NUMBER = looseIndex()(enumify(STATE.NUMBER, false)); export type NUMBER = typeof NUMBER; export type Number = KeyOf @@ -151,7 +151,7 @@ export const DURATIONS = enumify(STATE.DURATIONS, false); export type DURATIONS = KeyOf /** common format aliases */ -export const FORMAT = looseIndex()(proxify(enumify(STATE.FORMAT, false), true, false)); +export const FORMAT = looseIndex()(enumify(STATE.FORMAT, false)); export type FORMAT = typeof FORMAT; export type Format = LooseUnion & string> @@ -179,7 +179,7 @@ export const LIMIT = proxify(STATE.LIMIT, true, false); /** date-time element tokens */ const elementKeys = ['yy', 'mm', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const; -export const ELEMENT = proxify(enumify({ +export const ELEMENT = enumify({ yy: 'year', mm: 'month', ww: 'week', @@ -190,25 +190,25 @@ export const ELEMENT = proxify(enumify({ ms: 'millisecond', us: 'microsecond', ns: 'nanosecond', -}, false), true, false); +}, false); export type ELEMENT = ValueOf export type Element = KeyOf /** allowed mutation keys for .set() and .add() */ const mutationKeys = [...elementKeys, 'event', 'period', 'clock', 'time', 'date', 'start', 'mid', 'end'] as const; -export const MUTATION = proxify(enumify(mutationKeys, false), true, false); +export const MUTATION = enumify(mutationKeys, false); export type MUTATION = ValueOf export type Mutation = KeyOf /** allowed keys for ZonedDateTime-like objects */ -const zonedDateTimeKeys = ['value', 'timeZoneId', 'calendarId', 'monthCode', 'offset', 'timeZone', ...elementKeys] as const; -export const ZONED_DATE_TIME = proxify(enumify(zonedDateTimeKeys, false), true, false); +const zonedDateTimeKeys = ['value', 'timeZoneId', 'calendarId', 'monthCode', 'offset', 'timeZone', 'year', 'month', 'day', 'hour', 'minute', 'second', ...elementKeys] as const; +export const ZONED_DATE_TIME = enumify(zonedDateTimeKeys, false); export type ZONED_DATE_TIME = ValueOf export type ZonedDateTime = KeyOf /** allowed keys for Tempo configuration options */ const optionKeys = ['value', 'mode', 'mdyLocales', 'mdyLayouts', 'store', 'discovery', 'debug', 'catch', 'timeZone', 'calendar', 'locale', 'pivot', 'sphere', 'timeStamp', 'snippet', 'layout', 'event', 'period', 'formats', 'plugins'] as const; -export const OPTION = proxify(enumify(optionKeys, false), true, false); +export const OPTION = enumify(optionKeys, false); export type Option = KeyOf /** initialization strategies */ @@ -217,12 +217,12 @@ export type MODE = ValueOf /** allowed keys for internal parse state */ const parseKeys = ['mdyLocales', 'mdyLayouts', 'formats', 'pivot', 'snippet', 'layout', 'event', 'period', 'anchor', 'value', 'discovery', 'plugins', 'mode'] as const; -export const PARSE = proxify(enumify(parseKeys, false), true, false); +export const PARSE = enumify(parseKeys, false); export type Parse = KeyOf /** allowed keys for global discovery objects */ const discoveryKeys = ['options', 'timeZones', 'terms', 'plugins', 'numbers', 'formats'] as const; -export const DISCOVERY = proxify(enumify(discoveryKeys, false), true, false); +export const DISCOVERY = enumify(discoveryKeys, false); export type Discovery = KeyOf /** @internal LIVE Registries mapping (STATE key -> Enum/Proxy) */ diff --git a/packages/tempo/src/support/tempo.init.ts b/packages/tempo/src/support/tempo.init.ts index a1d28116..dc59a07a 100644 --- a/packages/tempo/src/support/tempo.init.ts +++ b/packages/tempo/src/support/tempo.init.ts @@ -3,6 +3,8 @@ import { enumify } from '#library/enumerate.library.js'; import { asArray } from '#library/coercion.library.js'; import { getDateTimeFormat, getHemisphere } from '#library/international.library.js'; import { markConfig } from '#library/symbol.library.js'; +import { asType } from '#library/type.library.js'; +import { isString, isObject, isUndefined, isDefined, isRegExp } from '#library/assertion.library.js'; import { ownEntries } from '#library/primitive.library.js'; import { getRuntime } from './tempo.runtime.js'; @@ -13,10 +15,11 @@ import enums, { STATE } from './tempo.enum.js'; import * as t from '../tempo.type.js'; import type { Mode } from '../tempo.type.js'; -/** @internal Initialise the global Tempo state */ -export function init(options: t.Options = {}): t.Internal.State { +/** @internal Initialise a Tempo state */ +export function init(options: t.Options = {}, isGlobal = true, baseState?: t.Internal.State): t.Internal.State { const runtime = getRuntime(); - if (runtime.state) return runtime.state; + // Global init is intentionally idempotent after first hydration; late-loaded modules must use Tempo.extend(). + if (isGlobal && runtime.state && !baseState) return runtime.state; const { timeZone, calendar } = getDateTimeFormat(); @@ -29,52 +32,75 @@ export function init(options: t.Options = {}): t.Internal.State { state.parse = markConfig({ token: Token, result: [], - snippet: Object.assign({}, Snippet), - layout: Object.assign({}, Layout), - event: Object.assign({}, Event), - period: Object.assign({}, Period), - ignore: Object.fromEntries(asArray(Ignore).map(w => [w, w])), - mdyLocales: asArray(Default.mdyLocales as any), - mdyLayouts: asArray(Default.mdyLayouts as any), - pivot: Default.pivot as any, - mode: Default.mode as any, + snippet: Object.assign({}, baseState?.parse.snippet ?? Snippet), + layout: Object.assign({}, baseState?.parse.layout ?? Layout), + event: Object.assign({}, baseState?.parse.event ?? Event), + period: Object.assign({}, baseState?.parse.period ?? Period), + ignore: baseState ? { ...baseState.parse.ignore } : Object.fromEntries(asArray(Ignore).map(w => [w, w])), + mdyLocales: asArray(baseState?.parse.mdyLocales ?? Default.mdyLocales as any), + mdyLayouts: asArray(baseState?.parse.mdyLayouts ?? Default.mdyLayouts as any), + pivot: (baseState?.parse.pivot ?? Default.pivot) as any, + mode: (baseState?.parse.mode ?? Default.mode) as any, lazy: false, - pattern: new Map(), + pattern: new Map(baseState?.parse.pattern), }); // 2. Establish the base configuration options - markConfig(Object.assign(state.config, Default)); - Object.defineProperties(state.config, { - calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, - timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, - locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, - discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, - formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, - sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, - get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, - scope: { value: 'global', enumerable: true, writable: true, configurable: true }, - catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } - }); + if (isGlobal) { + markConfig(Object.assign(state.config, Default)); + const { timeZone, calendar } = getDateTimeFormat(); + Object.defineProperties(state.config, { + calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, + timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, + locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, + discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, + formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, + sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, + get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, + scope: { value: 'global', enumerable: true, writable: true, configurable: true }, + catch: { value: options.catch ?? false, enumerable: true, writable: true, configurable: true } + }); + } else if (baseState) { + state.config = markConfig(Object.create(baseState.config)); + Object.defineProperties(state.config, { + calendar: { value: (state.config as any).calendar, enumerable: true, writable: true, configurable: true }, + timeZone: { value: (state.config as any).timeZone, enumerable: true, writable: true, configurable: true }, + locale: { value: (state.config as any).locale, enumerable: true, writable: true, configurable: true }, + discovery: { value: (state.config as any).discovery, enumerable: true, writable: true, configurable: true }, + formats: { value: (state.config as any).formats, enumerable: true, writable: true, configurable: true }, + sphere: { value: (state.config as any).sphere, enumerable: true, writable: true, configurable: true }, + get: { value: (state.config as any).get, enumerable: false, writable: true, configurable: true }, + scope: { value: 'local', enumerable: true, writable: true, configurable: true }, + }); + setProperty(state.config, 'catch', options.catch); + } else { + markConfig(Object.assign(state.config, Default)); + Object.defineProperties(state.config, { + calendar: { value: calendar, enumerable: true, writable: true, configurable: true }, + timeZone: { value: timeZone, enumerable: true, writable: true, configurable: true }, + locale: { value: (getDateTimeFormat() as any).locale ?? 'en-US', enumerable: true, writable: true, configurable: true }, + discovery: { value: Symbol.keyFor(sym.$Tempo) as string, enumerable: true, writable: true, configurable: true }, + formats: { value: enumify(STATE.FORMAT, false), enumerable: true, writable: true, configurable: true }, + sphere: { value: getHemisphere(timeZone), enumerable: true, writable: true, configurable: true }, + get: { value: function (key: string) { return this[key] }, enumerable: false, writable: true, configurable: true }, + scope: { value: 'local', enumerable: true, writable: true, configurable: true }, + }); + if (isDefined(options.catch)) + setProperty(state.config, 'catch', options.catch); + } // 3. Initialize registries that need objects state.OPTION = new Set(Object.keys(Default)); state.ZONED_DATE_TIME = new Set(['year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond', 'microsecond', 'nanosecond', 'offset', 'timeZone', 'calendar']); - runtime.state = state; + if (isGlobal) runtime.state = state; return state; } /** @internal Extend a Tempo state with new options (Shadowing) */ export function extendState(state: t.Internal.State, options: t.Options) { - const { - isString = (v: any) => typeof v === 'string', - isObject = (v: any) => typeof v === 'object' && v !== null, - isUndefined = (v: any) => v === undefined, - isRegExp = (v: any) => v instanceof RegExp, - asType = (v: any) => ({ type: Object.prototype.toString.call(v).slice(8, -1), value: v }) - } = getRuntime().modules['Library'] ?? {}; - let patternsDirty = false; + ownEntries(options).forEach(([optKey, optVal]) => { if (isUndefined(optVal)) return; const arg = asType(optVal); diff --git a/packages/tempo/src/support/tempo.register.ts b/packages/tempo/src/support/tempo.register.ts index c9b818e3..f74ff238 100644 --- a/packages/tempo/src/support/tempo.register.ts +++ b/packages/tempo/src/support/tempo.register.ts @@ -1,11 +1,11 @@ import { clearCache } from '#library/function.library.js'; -import { isDefined, isUndefined } from '#library/type.library.js'; +import { isDefined, isUndefined } from '#library/assertion.library.js'; import { ownKeys } from '#library/primitive.library.js'; -import { secureRef } from '#library/proxy.library.js'; -import lib from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; import type { Property } from '#library/type.library.js'; import { getRuntime } from './tempo.runtime.js'; +import { setProperty } from './tempo.util.js'; // Import the live enums and their mutable state from the enum module import { STATE, REGISTRIES, DEFAULTS } from './tempo.enum.js'; @@ -24,11 +24,11 @@ export function onRegistryReset(hook: () => void) { export function registryReset() { ownKeys(STATE).forEach(name => { const state = STATE[name as keyof typeof STATE] as Property; - const target = REGISTRIES[name]?.[lib.$Target] as Property; const defaults = DEFAULTS[name as keyof typeof DEFAULTS] as Property; + const target = unwrap(REGISTRIES[name] as any); // 1. Purge all own-properties from state and target (if configurable) - [state, target].filter(isDefined).forEach(obj => { + [state, target].filter(obj => obj != null).forEach(obj => { Reflect.ownKeys(obj).forEach(key => { const desc = Object.getOwnPropertyDescriptor(obj, key); if (desc?.configurable) delete obj[key]; @@ -40,7 +40,7 @@ export function registryReset() { const desc = Object.getOwnPropertyDescriptor(defaults, key); if (desc) { - [state, target].filter(isDefined).forEach(obj => { + [state, target].filter(obj => obj != null).forEach(obj => { Object.defineProperty(obj, key, desc); }); } @@ -62,19 +62,15 @@ export function registryReset() { /** update a global registry with new discoverable data */ export function registryUpdate(name: keyof typeof STATE, data: Record) { const registry = REGISTRIES[name]; - if (!isDefined(registry) || !isDefined(registry[lib.$Target])) return; - - const target = registry[lib.$Target] as Property; const state = STATE[name] as Property; + const target = unwrap(registry) as Property; + + if (!isDefined(target) || target === registry) + return; Object.entries(data).forEach(([key, val]) => { if (isUndefined(target[key])) { // only add if key does not exist - Object.defineProperty(target, key, { - value: val, - enumerable: true, - writable: true, - configurable: true - }); + setProperty(target, key, val); if (isDefined(state)) state[key] = val; } }); diff --git a/packages/tempo/src/support/tempo.symbol.ts b/packages/tempo/src/support/tempo.symbol.ts index 01d7a7bb..bc1f4ca3 100644 --- a/packages/tempo/src/support/tempo.symbol.ts +++ b/packages/tempo/src/support/tempo.symbol.ts @@ -1,41 +1,54 @@ import { looseIndex } from '#library/object.library.js'; +import { sym as lib, $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $Register as $LibRegister, $SerializerRegistry, $Identity } from '#library/symbol.library.js'; +export { $Target, $Discover, $Extensible, $Inspect, $Logify, $Registry, $LibRegister, $SerializerRegistry, $Identity }; /** check valid Tempo instance */ -export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$isTempo]); +export const isTempo = (tempo?: any): tempo is TempoBrand => Boolean(tempo?.[sym.$Identity]); /** * Centralized registry for all Tempo-specific Global Symbols. * These symbols utilize Symbol.for() to ensure consistency across module boundaries. - * Tempo-specific symbols are kept here (rather than @magmacomputing/library) to maintain - * clean separation of concerns. */ -export const IsTempo: unique symbol = Symbol.for('magmacomputing/tempo/isTempo') as any; export const TermError: unique symbol = Symbol.for('magmacomputing/tempo/termError') as any; -/** @internal Tempo Symbol Registry */ -export const sym = { - /** key for Global Discovery of Tempo configuration */ $Tempo: Symbol.for('$Tempo'), - /** key for Reactive Plugin Registration */ $Register: Symbol.for('magmacomputing/tempo/register'), - /** key for Global Identity Brand for Tempo */ $isTempo: IsTempo, - /** key for centralized Term Error dispatching */ $termError:TermError, - /** key for Internal Interpreter Service */ $Interpreter: Symbol.for('magmacomputing/tempo/interpreter'), - /** key for contextual Error Logging */ $logError: Symbol.for('magmacomputing/tempo/logError'), - /** key for contextual Debug Logging */ $logDebug: Symbol.for('magmacomputing/tempo/logDebug'), - /** key for contextual Debugger */ $dbg: Symbol.for('magmacomputing/tempo/dbg'), - /** key for Master Guard */ $guard: Symbol.for('magmacomputing/tempo/guard'), - /** internal key for signaling pre-errored state */ $errored: Symbol.for('magmacomputing/tempo/errored'), - /** internal key for accessing private instance state */ $Internal: Symbol.for('magmacomputing/tempo/internal'), - /** hardened globalThis bridge key for the TempoRuntime */$Bridge: Symbol.for('magmacomputing/tempo/runtime'), - /** cross-bundle brand check for TempoRuntime */ $RuntimeBrand: Symbol.for('magmacomputing/tempo/runtime/brand'), - /** branding for explicit PropertyDescriptors */ $Descriptor: Symbol.for('magmacomputing/tempo/descriptor'), +/** @internal unique symbols for critical internal accessors */ +/** key for Global Discovery of Tempo configuration */ export const $Tempo: unique symbol = Symbol.for('$Tempo') as any; +/** key for Reactive Plugin Registration */ export const $Register: unique symbol = Symbol.for('magmacomputing/tempo/register') as any; +/** key for Internal Interpreter Service */ export const $Interpreter: unique symbol = Symbol.for('magmacomputing/tempo/interpreter') as any; +/** key for contextual Error Logging */ export const $logError: unique symbol = Symbol.for('magmacomputing/tempo/logError') as any; +/** key for contextual Debug Logging */ export const $logDebug: unique symbol = Symbol.for('magmacomputing/tempo/logDebug') as any; +/** key for contextual Debugger */ export const $dbg: unique symbol = Symbol.for('magmacomputing/tempo/dbg') as any; +/** key for Master Guard */ export const $guard: unique symbol = Symbol.for('magmacomputing/tempo/guard') as any; +/** internal key for signaling pre-errored state */ export const $errored: unique symbol = Symbol.for('magmacomputing/tempo/errored') as any; +/** internal key for accessing private instance state */ export const $Internal: unique symbol = Symbol.for('magmacomputing/tempo/internal') as any; +/** hardened globalThis bridge key for the TempoRuntime */export const $Bridge: unique symbol = Symbol.for('magmacomputing/tempo/runtime') as any; +/** cross-bundle brand check for TempoRuntime */ export const $RuntimeBrand: unique symbol = Symbol.for('magmacomputing/tempo/runtime/brand') as any; +/** branding for explicit PropertyDescriptors */ export const $Descriptor: unique symbol = Symbol.for('magmacomputing/tempo/descriptor') as any; + +/** internal static config helper */ export const $setConfig: unique symbol = Symbol.for('magmacomputing/tempo/setConfig') as any; +/** internal static discovery helper */ export const $setDiscovery: unique symbol = Symbol.for('magmacomputing/tempo/setDiscovery') as any; +/** internal static event builder */ export const $setEvents: unique symbol = Symbol.for('magmacomputing/tempo/setEvents') as any; +/** internal static period builder */ export const $setPeriods: unique symbol = Symbol.for('magmacomputing/tempo/setPeriods') as any; +/** internal static guard builder */ export const $buildGuard: unique symbol = Symbol.for('magmacomputing/tempo/buildGuard') as any; +/** internal static base class marker */ export const $IsBase: unique symbol = Symbol.for('magmacomputing/tempo/isBase') as any; + +/** @internal Tempo Symbol Registry (Local Keys) */ +const local = { + $Tempo, $Register, $Interpreter, $logError, $logDebug, $dbg, $guard, $errored, + $Internal, $Bridge, $RuntimeBrand, $Descriptor, $setConfig, $setDiscovery, + $setEvents, $setPeriods, $buildGuard, $IsBase } as const; -/** @internal Local interface for brand checking without circular imports */ -export interface TempoBrand { - [sym.$isTempo]: true; +/** @internal Unified Symbol Registry (Inherits from #library via Prototype Chain) */ +export const sym = Object.assign(Object.create(lib), local) as Omit & typeof local; + +/** @internal Local type for brand checking without circular imports */ +export type TempoBrand = { + [sym.$Identity]: true; toDateTime(): Temporal.ZonedDateTime; config: any; + parse: any; } /** @internal Tempo Token registry */ @@ -64,7 +77,12 @@ export const Token = looseIndex()({ // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Layout Symbols /** date */ dt: Symbol('date'), /** time */ tm: Symbol('time'), + /** compact time (hhmiss) */ hms: Symbol('hourMinuteSecond'), + /** compact day-month-year (ddmmyy) */ dmy6: Symbol('dayMonthYearShort'), + /** compact month-day-year (mmddyy) */ mdy6: Symbol('monthDayYearShort'), + /** compact year-month-day (yymmdd) */ ymd6: Symbol('yearMonthDayShort'), /** date and time */ dtm: Symbol('dateTime'), + /** time and date */ tmd: Symbol('timeDate'), /** day-month-year */ dmy: Symbol('dayMonthYear'), /** month-day-year */ mdy: Symbol('monthDayYear'), /** year-month-day */ ymd: Symbol('yearMonthDay'), diff --git a/packages/tempo/src/support/tempo.util.ts b/packages/tempo/src/support/tempo.util.ts index 77de5800..93305a1c 100644 --- a/packages/tempo/src/support/tempo.util.ts +++ b/packages/tempo/src/support/tempo.util.ts @@ -1,5 +1,6 @@ import { sym, Token } from './tempo.symbol.js'; -import { isSymbol, isUndefined, isString, isRegExp, isNullish, isRegExpLike, asType } from '#library/type.library.js'; +import { asType } from '#library/type.library.js'; +import { isSymbol, isUndefined, isString, isRegExp, isNullish, isObject, isEmpty } from '#library/assertion.library.js'; import { ownEntries, ownKeys } from '#library/primitive.library.js'; import { getRuntime } from './tempo.runtime.js'; import { Match, Snippet, Layout } from './tempo.default.js'; @@ -14,14 +15,14 @@ export const setProperty = (target: object, key: PropertyKey, value: T) => export const proto = (obj: object) => Object.getPrototypeOf(obj); /** @internal test object has own property with the given key */ -export const hasOwn = (obj: object, key: string) => Object.hasOwn(obj, key); +export const hasOwn = (obj: object, key: PropertyKey) => Object.hasOwn(obj, key); /** @internal create an object based on a prototype */ export const create = (obj: object, name: string): T => { const entry = proto(obj)[name]; - if (typeof entry !== 'object' || entry === null) { + if (!isObject(entry)) throw new TypeError(`[Tempo#create] Failed to create shadowed object for '${name}'. The prototype entry from proto(obj) is missing or not an object (received: ${typeof entry}).`); - } + return { ...entry } as T; }; @@ -124,7 +125,7 @@ export function compileRegExp(layout: string | RegExp, state: t.Internal.State, } } -const isEmpty = (v: any) => !v || (Array.isArray(v) && v.length === 0) || (typeof v === 'object' && Object.keys(v).length === 0); + /** @internal build RegExp patterns into the state */ export function setPatterns(state: t.Internal.State) { @@ -138,6 +139,7 @@ export function setPatterns(state: t.Internal.State) { if (enums?.NUMBER) { const keys = Object.keys(enums.NUMBER).map(w => Match.escape(w)); // escape each key const nbr = new RegExp(`(?[0-9]+|${keys.sort((a, b) => b.length - a.length).join('|')})`); + snippet[Token.nbr] = nbr; snippet[Token.mod] = new RegExp(`((?${Match.modifier.source})?${nbr.source}? *)`); snippet[Token.afx] = new RegExp(`((s)? (?${Match.affix.source}))?${snippet[Token.sep].source}?`); @@ -145,11 +147,13 @@ export function setPatterns(state: t.Internal.State) { // 2. build ignore pattern const ignores = ownKeys(state.parse.ignore, true); + if (!isEmpty(ignores)) { const words = ignores .filter(isString) .map(w => Match.escape(w.toLowerCase())) .join('|'); + state.parse.ignorePattern = new RegExp(`\\b(${words})\\b`, 'gi'); } else { delete state.parse.ignorePattern; @@ -159,7 +163,7 @@ export function setPatterns(state: t.Internal.State) { ownEntries(state.parse.layout).forEach(([key, layout]) => { const symbol = getSymbol(key); const compiled = compileRegExp(layout, state, snippet); + state.parse.pattern.set(symbol, compiled); - // console.log(`DEBUG Compiled [${String(symbol)}]:`, compiled.source.substring(0, 50) + '...'); }); } diff --git a/packages/tempo/src/tempo.class.ts b/packages/tempo/src/tempo.class.ts index 584fec69..9e0684de 100644 --- a/packages/tempo/src/tempo.class.ts +++ b/packages/tempo/src/tempo.class.ts @@ -1,25 +1,27 @@ import '#library/temporal.polyfill.js'; import { Logify } from '#library/logify.class.js'; -import { secure } from '#library/utility.library.js'; import { Immutable, Serializable } from '#library/class.library.js'; import { asArray } from '#library/coercion.library.js'; import { getStorage, setStorage } from '#library/storage.library.js'; -import { proxify, delegate } from '#library/proxy.library.js'; -import lib, { markConfig } from '#library/symbol.library.js'; +import { secure, proxify, delegate } from '#library/proxy.library.js'; import { getContext, CONTEXT } from '#library/utility.library.js'; import { enumify } from '#library/enumerate.library.js'; -import { ownKeys, ownEntries } from '#library/primitive.library.js'; +import { ownKeys, ownEntries, unwrap } from '#library/primitive.library.js'; import { getAccessors, omit } from '#library/reflection.library.js'; import { pad, trimAll } from '#library/string.library.js'; -import { getType, asType, isEmpty, isDefined, isUndefined, isString, isObject, isRegExp, isSymbol, isFunction, isClass, isZonedDateTime, Property, Secure } from '#library/type.library.js'; +import { getType, asType } from '#library/type.library.js'; +import { isEmpty, isDefined, isUndefined, isString, isObject, isRegExp, isSymbol, isFunction, isClass, isZonedDateTime, isDurationLike, isZonedDateTimeLike } from '#library/assertion.library.js'; +import type { Property, Secure } from '#library/type.library.js'; import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js'; + import { registerPlugin, interpret, ensureModule } from './plugin/plugin.util.js' import { registerTerm, getTermRange } from './plugin/term.util.js'; +import { resolveLayoutOrder, getLayoutOrder } from './engine/engine.layout.js'; import type { TermPlugin, Plugin } from './plugin/plugin.type.js'; import { setProperty, proto, hasOwn, create, compileRegExp, setPatterns } from './support/tempo.util.js'; -import { sym, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, type TempoBrand } from '#tempo/support'; +import { sym, markConfig, TermError, getRuntime, init, isTempo, registryUpdate, registryReset, onRegistryReset, Match, Token, Snippet, Layout, Event, Period, Ignore, Default, Guard, enums, STATE, DISCOVERY, $Internal, $setConfig, $logError, $logDebug, $Identity, $setEvents, $setPeriods, $buildGuard, $IsBase, type TempoBrand, $Tempo, $Register, $Logify, $errored, $dbg, $guard, $Discover, $setDiscovery } from '#tempo/support'; import * as t from './tempo.type.js'; // namespaced types (Tempo.*) import { instant } from '#library/temporal.library.js'; @@ -34,6 +36,7 @@ const Context = getContext(); // current execution context /** return whether the shape is 'local' or 'global' */ const isLocal = (shape: { config: { scope: string } }) => shape.config.scope === 'local'; +const ClassStates = new WeakMap(); // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ namespace Internal { export type State = t.Internal.State; @@ -58,30 +61,30 @@ namespace Internal { */ @Serializable @Immutable -export class Tempo implements TempoBrand { +export class Tempo { /** Weekday names (short-form) */ static get WEEKDAY() { return enums.WEEKDAY } /** Weekday names (long-form) */ static get WEEKDAYS() { return enums.WEEKDAYS } - /** Month names (short-form) */ static get MONTH() { return enums.MONTH } - /** Month names (long-form) */ static get MONTHS() { return enums.MONTHS } + /** Month names (short-form) */ static get MONTH() { return enums.MONTH } + /** Month names (long-form) */ static get MONTHS() { return enums.MONTHS } /** Time durations as seconds (singular) */ static get DURATION() { return enums.DURATION } - /** Time durations as milliseconds (plural) */ static get DURATIONS() { return enums.DURATIONS } + /** Time durations as milliseconds (plural) */ static get DURATIONS() { return enums.DURATIONS } /** Quarterly Seasons */ static get SEASON() { return enums.SEASON } /** Compass cardinal points */ static get COMPASS() { return enums.COMPASS } - /** Tempo to Temporal DateTime Units map */ static get ELEMENT() { return enums.ELEMENT } + /** Tempo to Temporal DateTime Units map */ static get ELEMENT() { return enums.ELEMENT } /** Pre-configured format {name -> string} pairs */ static get FORMAT() { return enums.FORMAT } /** Number names (0-10) */ static get NUMBER() { return enums.NUMBER } /** TimeZone aliases */ static get TIMEZONE() { return enums.TIMEZONE } /** initialization strategies */ static get MODE() { return enums.MODE } - /** some useful Dates */ static get LIMIT() { return enums.LIMIT } + /** some useful Dates */ static get LIMIT() { return enums.LIMIT } /** @internal check if Tempo is currently initializing */ static get isInitializing() { return !Tempo.#lifecycle.ready } /** @internal check if Tempo is currently extending */ static get isExtending() { return Tempo.#lifecycle.extendDepth > 0 } /** Logify for internal errors and debug logs */ static #dbg = new Logify('Tempo', { debug: Default?.debug ?? false, catch: Default?.catch ?? false }) - /** Tempo state for the global configuration */ static #global = {} as Internal.State + /** Tempo state for the global configuration */ static #global = {} as Internal.State; /** cache for next-available 'usr' Token key */ static #usrCount = 0; /** mutable list of registered term plugins */ static get #terms(): TermPlugin[] { return getRuntime().pluginsDb.terms } /** mapping of terms to their resolved values */ static #termMap: Map = new Map(); @@ -89,23 +92,32 @@ export class Tempo implements TempoBrand { /** Master Guard predicate (implements RegExp-like interface) */static #guard: { test(str: string): boolean } = { test: () => true }; /** Set of allowed lowercased tokens for the Master Guard */ static #allowedTokens: Set = new Set(); + static [$IsBase] = true; + /** @internal Static access to global private state. */ - static [sym.$Internal]() { - return Tempo.#global; + static [$Internal]() { + return ClassStates.get(this) ?? Tempo.#global; } + /** @internal brand check to distinguish Tempo objects from other objects */ + get [$Identity](): true { return true } /** @internal handle internal errors using the global config */ - static [sym.$logError](...msg: any[]): void { - const config = (isObject(msg[0]) && (msg[0] as any)[lib.$Logify] === true) ? msg.shift() : Tempo.#global.config; + static [$logError](...msg: any[]): void { + const provided = (isObject(msg[0]) && (msg[0] as any)[$Logify] === true) ? msg.shift() : undefined; + const global = (this as any)[$Internal]().config; + const config = markConfig(Object.create(global)); + if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); markConfig(config); // ensure config is marked for Logify Tempo.#dbg.error(config, ...msg); } /** @internal handle internal debug logs */ - static [sym.$logDebug](...args: any[]): void { - const config = (isObject(args[0]) && (args[0] as any)[lib.$Logify] === true) ? args.shift() : Tempo.#global.config; - markConfig(config); + static [$logDebug](...args: any[]): void { + const provided = (isObject(args[0]) && (args[0] as any)[$Logify] === true) ? args.shift() : undefined; + const global = (this as any)[$Internal]().config; + const config = markConfig(Object.create(global)); + if (provided) Object.entries(provided).forEach(([k, v]) => setProperty(config, k, v)); Tempo.#dbg.debug(config, ...args); } @@ -115,7 +127,7 @@ export class Tempo implements TempoBrand { * because it will also include a list of events (e.g. 'new_years' | 'xmas'), we need to rebuild {dt} if the user adds a new event */ // TODO: check all Layouts which reference "{evt}" and update them - static #setEvents(shape: Internal.State) { + static [$setEvents](shape: Internal.State) { const events = ownEntries(shape.parse.event, true); if (isLocal(shape) && !hasOwn(shape.parse, 'event') && !hasOwn(shape.parse, 'isMonthDay')) return; // no local change needed @@ -133,6 +145,11 @@ export class Tempo implements TempoBrand { setProperty(shape.parse.snippet, Token.evt, new RegExp(groups)); } + } else { + // If no groups, ensure we don't have a stale or empty regex that could cause issues + if (hasOwn(shape.parse.snippet, Token.evt)) { + delete shape.parse.snippet[Token.evt as any]; + } } if (shape.parse.isMonthDay) { @@ -152,7 +169,7 @@ export class Tempo implements TempoBrand { * because it will also include a list of periods (e.g. 'midnight' | 'afternoon' ), we need to rebuild {tm} if the user adds a new period */ // TODO: check all Layouts which reference "{per}" and update them - static #setPeriods(shape: Internal.State) { + static [$setPeriods](shape: Internal.State) { const periods = ownEntries(shape.parse.period, true); if (isLocal(shape) && !hasOwn(shape.parse, 'period')) return; // no local change needed @@ -170,6 +187,11 @@ export class Tempo implements TempoBrand { setProperty(shape.parse.snippet, Token.per, new RegExp(groups)); } + } else { + // If no groups, ensure we don't have a stale or empty regex that could cause issues + if (hasOwn(shape.parse.snippet, Token.per)) { + delete shape.parse.snippet[Token.per as any]; + } } } @@ -190,7 +212,7 @@ export class Tempo implements TempoBrand { /** determine if we have a {timeZone} which prefers {mdy} date-order */ static #isMonthDay(shape: Internal.State) { - const monthDay = [...asArray(Tempo.#global.parse.mdyLocales)]; + const monthDay = [...asArray((this as any)[$Internal]().parse.mdyLocales)]; if (isLocal(shape) && hasOwn(shape.parse, 'mdyLocales')) monthDay.push(...shape.parse.mdyLocales); // append local mdyLocales (not overwrite global) @@ -207,29 +229,16 @@ export class Tempo implements TempoBrand { * this allows the parser to try to interpret '04012023' as Apr-01-2023 before trying 04-Jan-2023 */ static #swapLayout(shape: Internal.State) { - const layouts = ownEntries(shape.parse.layout); // get entries of Layout Record - const swap = shape.parse.mdyLayouts; // get the swap-tuple - let chg = false; // no need to rebuild, if no change - - swap - .forEach(([dmy, mdy]) => { // loop over each swap-tuple - const idx1 = layouts.findIndex(([key]) => (key as symbol).description === dmy); // 1st swap element exists in {layouts} - const idx2 = layouts.findIndex(([key]) => (key as symbol).description === mdy); // 2nd swap element exists in {layouts} - - if (idx1 === -1 || idx2 === -1) - return; // no pair to swap - - const swap1 = (idx1 < idx2) && shape.parse.isMonthDay; // we prefer {mdy} and the 1st tuple was found earlier than the 2nd - const swap2 = (idx1 > idx2) && !shape.parse.isMonthDay; // we dont prefer {mdy} and the 1st tuple was found later than the 2nd + const layout = resolveLayoutOrder({ + layout: shape.parse.layout, + mdyLayouts: shape.parse.mdyLayouts, + isMonthDay: !!shape.parse.isMonthDay, + }); - if (swap1 || swap2) { // since {layouts} is an array, ok to swap by-reference - [layouts[idx1], layouts[idx2]] = [layouts[idx2], layouts[idx1]]; - chg = true; - } - }) + if (layout !== shape.parse.layout) + shape.parse.layout = layout as Layout; - if (chg) - shape.parse.layout = Object.fromEntries(layouts) as Layout; // rebuild Layout in new parse order + Tempo.#dbg.debug(shape.config, `Resolved layout order: ${getLayoutOrder(layout).join(' -> ')}`); } /** get first Canonical name of a supplied locale */ @@ -249,11 +258,22 @@ export class Tempo implements TempoBrand { locale // cannot determine locale } + /** detect likely overlap between two alias keys/patterns */ + static #isAliasCollision(a: string, b: string): boolean { + const left = a.trim().toLowerCase(); + const right = b.trim().toLowerCase(); + + if (!left || !right) return false; + if (left === right) return true; + + return left.includes(right) || right.includes(left); + } + /** * conform input of Snippet / Layout / Event / Period options * This is needed because we allow the user to flexibly provide detail as {[key]:val} or {[key]:val}[] or [key,val][] */ - static #setConfig(shape: Internal.State, ...options: t.Options[]) { + static [$setConfig](shape: Internal.State, ...options: t.Options[]) { const providedOptions: t.Options = Object.assign({}, ...options); const storeKey = providedOptions.store; const mergedOptions: t.Options = storeKey @@ -308,14 +328,44 @@ export class Tempo implements TempoBrand { : isRegExp(v) ? v.source : v ) } else { + const aliases: [string, any][] = []; asArray(arg.value) .forEach(elm => { if (isObject(elm)) { - Object.assign(rule, elm); + ownEntries(elm as Record, true) + .forEach(([k, v]) => aliases.push([String(k), v])); } else if (isString(elm)) { - rule[elm] = elm; + aliases.push([elm, elm]); + } + }); + + if ((optKey === 'event' || optKey === 'period') && aliases.length > 0) { + const existing = ownEntries(rule as Record, true) + .map(([k, v]) => [String(k), v] as [string, any]); + const incomingKeys = new Set(aliases.map(([k]) => k)); + + aliases.forEach(([incomingKey]) => { + const collisions = existing + .map(([k]) => k) + .filter(k => !incomingKeys.has(k) && Tempo.#isAliasCollision(k, incomingKey)); + + if (!isEmpty(collisions)) { + Tempo.#dbg.warn(shape.config, + `Potential ${optKey} alias collision: "${incomingKey}" overlaps with existing alias(es): ${collisions.join(', ')}`); } - }) + }); + + const next = Object.fromEntries([ + ...aliases, + ...existing.filter(([k]) => !incomingKeys.has(k)) + ]); + + ownKeys(rule as Record, true) + .forEach(key => delete (rule as any)[key]); + Object.assign(rule, next); + } else { + Object.assign(rule, Object.fromEntries(aliases)); + } } break; @@ -332,7 +382,7 @@ export class Tempo implements TempoBrand { break; case 'config': - Tempo.#setConfig(shape, arg.value as t.Options); + (this as any)[$setConfig](shape, arg.value as t.Options); break; case 'timeZone': { @@ -382,9 +432,9 @@ export class Tempo implements TempoBrand { shape.config.sphere = Tempo.#setSphere(shape, mergedOptions); - if (isDefined(shape.parse.mdyLayouts)) Tempo.#swapLayout(shape); - if (isDefined(shape.parse.event)) Tempo.#setEvents(shape); - if (isDefined(shape.parse.period)) Tempo.#setPeriods(shape); + if (isDefined(shape.parse.mdyLocales)) Tempo.#swapLayout(shape); + if (isDefined(shape.parse.event)) (this as any)[$setEvents](shape); + if (isDefined(shape.parse.period)) (this as any)[$setPeriods](shape); setPatterns(shape); // setup Regex DateTime patterns } @@ -400,7 +450,7 @@ export class Tempo implements TempoBrand { } /** support "Global Discovery" of user-options */ - static #setDiscovery(shape: Internal.State, discovery?: Internal.Discovery) { + static [$setDiscovery](shape: Internal.State, discovery?: Internal.Discovery) { if (!isObject(discovery)) return {} markConfig(discovery); // auto-mark the discovery object @@ -444,15 +494,14 @@ export class Tempo implements TempoBrand { const res = isFunction(opts) ? opts() : opts; if (shape === Tempo.#global) { - Tempo.#buildGuard(); + (this as any)[$buildGuard](); setPatterns(shape); } return res; } - static #buildGuard() { - // Tempo.#dbg.error(Tempo.#global.config, 'Building Guard...'); + static [$buildGuard]() { const wordsList = [ ...Object.keys(enums.NUMBER), ...Object.keys(enums.WEEKDAY), @@ -462,11 +511,11 @@ export class Tempo implements TempoBrand { ...Object.keys(enums.DURATION), ...Object.keys(enums.DURATIONS), ...Object.keys(enums.TIMEZONE), - ...ownKeys(Tempo.#global.parse.event), - ...ownKeys(Tempo.#global.parse.period), - ...ownKeys(Tempo.#global.parse.ignore), - ...ownKeys(Tempo.#global.parse.snippet), - ...ownKeys(Tempo.#global.parse.layout), + ...ownKeys((this as any)[$Internal]().parse.event), + ...ownKeys((this as any)[$Internal]().parse.period), + ...ownKeys((this as any)[$Internal]().parse.ignore), + ...ownKeys((this as any)[$Internal]().parse.snippet), + ...ownKeys((this as any)[$Internal]().parse.layout), ...[Token.slk], ...Tempo.#terms.map(t => t.key), ...Tempo.#terms.map(t => t.scope), @@ -535,6 +584,10 @@ export class Tempo implements TempoBrand { return true; } } + + if ((this as any)[$Internal]() === Tempo.#global) { + setPatterns((this as any)[$Internal]()); + } } /** @internal resolve a global discovery config object by symbol key */ @@ -591,7 +644,7 @@ export class Tempo implements TempoBrand { } catch (e: any) { const msg = (e?.message ?? '').toLowerCase(); if (msg.includes('constructor') || msg.includes('class') || (e instanceof TypeError) || isClass(arg)) { - Tempo.#dbg.warn(Tempo.#global.config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); + Tempo.#dbg.warn((this as any)[$Internal]().config, `Misidentified class in plugin registration: ${(arg as any).name}`, e.stack ?? e); } else { throw e; } @@ -602,7 +655,7 @@ export class Tempo implements TempoBrand { const name = (item as any).name; const rt = getRuntime(); if (rt.installed.has(name)) { - Tempo.#dbg.debug(Tempo.#global.config, `Plugin already installed by name: ${name}`); + Tempo.#dbg.debug((this as any)[$Internal]().config, `Plugin already installed by name: ${name}`); return; } rt.installed.add(name); @@ -623,7 +676,7 @@ export class Tempo implements TempoBrand { // 3. sync with parser registries if (config.scope && config.ranges) { - const target = config.scope === 'period' ? Tempo.#global.parse.period : (config.scope === 'event' ? Tempo.#global.parse.event : undefined); + const target = config.scope === 'period' ? (this as any)[sym.$Internal]().parse.period : (config.scope === 'event' ? (this as any)[sym.$Internal]().parse.event : undefined); if (target) { config.ranges.forEach(r => { if (r.key && !target[r.key]) { @@ -631,8 +684,8 @@ export class Tempo implements TempoBrand { if (val) target[r.key] = val; } }); - if (config.scope === 'period') Tempo.#setPeriods(Tempo.#global); - if (config.scope === 'event') Tempo.#setEvents(Tempo.#global); + if (config.scope === 'period') (this as any)[$setPeriods]((this as any)[sym.$Internal]()); + if (config.scope === 'event') (this as any)[$setEvents]((this as any)[sym.$Internal]()); } } } @@ -641,9 +694,9 @@ export class Tempo implements TempoBrand { const discovery = item as any if (discovery.term) { discovery.terms = [...asArray(discovery.terms || []), ...asArray(discovery.term)]; - Tempo.#dbg.warn(Tempo.#global.config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); + Tempo.#dbg.warn((this as any)[$Internal]().config, 'Legacy "term" key in Discovery is deprecated. Please use "terms" instead.'); } - if (discovery.options) Tempo.#setConfig(Tempo.#global, discovery.options) + if (discovery.options) (this as any)[$setConfig]((this as any)[$Internal](), discovery.options) if (discovery.plugins) this.extend(discovery.plugins, discovery.options) if (discovery.terms) this.extend(discovery.terms) @@ -654,16 +707,16 @@ export class Tempo implements TempoBrand { registryUpdate('TIMEZONE', tzs) } if (discovery.formats) { - Tempo.#global.config.formats = Tempo.#global.config.formats.extend(discovery.formats) as t.FormatRegistry; + (this as any)[$Internal]().config.formats = (this as any)[$Internal]().config.formats.extend(discovery.formats) as t.FormatRegistry; registryUpdate('FORMAT', discovery.formats) } // only trigger init if we're assigning a new discovery object to a symbol if (ownKeys(item).some(key => DISCOVERY.has(key as any))) { - const discoverySymbol = (typeof options === 'symbol' ? options : options?.discovery) ?? sym.$Tempo + const discoverySymbol = (isSymbol(options) ? options : (options as any)?.discovery) ?? sym.$Tempo if ((globalThis as Record)[discoverySymbol] !== item) { - ; (globalThis as Record)[discoverySymbol] = item - Tempo.#setConfig(Tempo.#global, { discovery: discoverySymbol }) + (globalThis as Record)[discoverySymbol] = item; + (this as any)[$setConfig]((this as any)[$Internal](), { discovery: discoverySymbol }) } } } @@ -674,15 +727,47 @@ export class Tempo implements TempoBrand { } if (Tempo.#lifecycle.extendDepth === 0) { - Tempo.#buildGuard(); - setPatterns(Tempo.#global); // rebuild the global patterns + (this as any)[$buildGuard](); + setPatterns((this as any)[$Internal]()); // rebuild the global patterns } return this; } + /** + * 🏭 Sandbox Factory Mode + * Create a fresh, isolated Tempo subclass with its own configuration and registries. + * Returns a new class that inherits from the caller, but maintains independent state. + */ + static create(options: t.Options = {}): typeof Tempo { + const SandboxTempo = class extends (this as any) { + static [Symbol.toStringTag] = 'TempoSandbox'; + } + + const discovery = options.discovery ?? Symbol('TempoSandbox'); + (globalThis as any)[discovery] = { options: { ...options }, scope: 'sandbox' }; + + const state = init(options, false, (this as any)[$Internal]()); + state.config.discovery = discovery; + ClassStates.set(SandboxTempo as any, state); + + // Apply configuration to the sandbox + (SandboxTempo as any)[$setConfig](state, + { + scope: 'local', + discovery, + catch: options.catch ?? false + }, + options + ); + + Object.freeze(SandboxTempo); + return SandboxTempo as unknown as typeof Tempo; + } + /** Reset Tempo to its default, built-in registration state */ static init(options: t.Options = {}): typeof Tempo { + if (Tempo.#lifecycle.initialising) return this; Tempo.#lifecycle.initialising = true; @@ -690,10 +775,14 @@ export class Tempo implements TempoBrand { const rt = getRuntime(); rt.state = undefined; // force fresh state const state = init(); - Tempo.#global = state; + if ((this as any)[sym.$IsBase]) { + Tempo.#global = state; + } else { + ClassStates.set(this, state); + } // 1. Augment the parsing state (non-destructively) - const parse = Tempo.#global.parse; + const parse = state.parse; parse.pattern ??= new Map(); parse.mdyLocales = Tempo.#mdyLocales(Default.mdyLocales as t.Options['mdyLocales']); parse.mdyLayouts = asArray(Default.mdyLayouts as t.Options['mdyLayouts']) as t.Pair[]; @@ -705,9 +794,9 @@ export class Tempo implements TempoBrand { const sys = getDateTimeFormat(); const timeZone = options.timeZone ?? sys.timeZone; const calendar = options.calendar ?? sys.calendar; - const config = Tempo.#global.config; - const discoveryKey = options.discovery ?? Symbol.keyFor(sym.$Tempo) as string; - const storeKey = options.store || config.store || Symbol.keyFor(sym.$Tempo) as string; + const config = state.config; + const discoveryKey = options.discovery ?? Symbol.keyFor($Tempo) as string; + const storeKey = options.store || config.store || Symbol.keyFor($Tempo) as string; const userDiscovery = (globalThis as any)[isString(discoveryKey) ? Symbol.for(discoveryKey) : discoveryKey] as Internal.Discovery; // Resolve locale if missing or invalid @@ -730,7 +819,7 @@ export class Tempo implements TempoBrand { registryReset(); // purge formats and numbers // 3. Apply configuration via unified setters (non-destructive merge) - Tempo.#setConfig(Tempo.#global, + (this as any)[$setConfig](state, { calendar, timeZone, @@ -741,19 +830,19 @@ export class Tempo implements TempoBrand { catch: options.catch ?? config.catch ?? false }, { store: storeKey, discovery: storeKey, scope: 'global' }, - Tempo.readStore(storeKey), // allow for storage-values to overwrite - Tempo.#setDiscovery(Tempo.#global, rt.pluginsDb as any), // persistent library extensions - Tempo.#setDiscovery(Tempo.#global, userDiscovery), // user Discovery (Configuration bootstrapping) + this.readStore(storeKey), // allow for storage-values to overwrite + (this as any)[$setDiscovery](state, rt.pluginsDb as any), // persistent library extensions + (this as any)[$setDiscovery](state, userDiscovery), // user Discovery (Configuration bootstrapping) options, // explicit options from the call ) if (options.plugins) this.extend(options.plugins); // ensure init-plugins are processed before 'ready' if (Context.type === CONTEXT.Browser || options.debug === true) - Tempo.#dbg.info(Tempo.config, 'Tempo:', Tempo.#global.config); + Tempo.#dbg.info(this.config, 'Tempo:', state.config); Tempo.#lifecycle.ready = true; - setPatterns(Tempo.#global); // rebuild the global patterns (Master Guard etc) + setPatterns(state); // rebuild the global patterns (Master Guard etc) } finally { Tempo.#lifecycle.initialising = false; @@ -764,12 +853,12 @@ export class Tempo implements TempoBrand { } /** @internal Reads options from persistent storage (e.g., localStorage). */ - static readStore(key = Tempo.#global.config.store) { + static readStore(key = (this as any)[$Internal]().config.store) { return getStorage(key, {}); } /** @internal Writes configuration into persistent storage. */ - static writeStore(config?: t.Options, key = Tempo.#global.config.store) { + static writeStore(config?: t.Options, key = (this as any)[$Internal]().config.store) { return setStorage(key, config); } @@ -792,7 +881,7 @@ export class Tempo implements TempoBrand { /** @internal translates {layout} into an anchored, case-insensitive RegExp. */ static regexp(layout: string | RegExp, snippet?: Snippet) { - return compileRegExp(layout, Tempo.#global, snippet as any); + return compileRegExp(layout, (this as any)[$Internal](), snippet as any); } /** Compares two `Tempo` instances or date-time values. */ @@ -804,8 +893,9 @@ export class Tempo implements TempoBrand { /** global Tempo configuration */ static get config() { + const state = (this as any)[$Internal](); const out = Object.create(Default); - const descriptors = omit(Object.getOwnPropertyDescriptors(Tempo.#global.config), 'value', 'anchor'); + const descriptors = omit(Object.getOwnPropertyDescriptors(state.config), 'value', 'anchor'); Object.defineProperties(out, descriptors); Object.defineProperty(out, 'toJSON', // bare-bones: only show global overrides @@ -822,11 +912,11 @@ export class Tempo implements TempoBrand { static get discovery() { const discovery = this.config.discovery; const sym = isString(discovery) ? Symbol.for(discovery) : discovery; - return Tempo.#getConfig(sym); + return Tempo.#getConfig(sym as symbol); } static get options() { - const keyFor = this.config.store ?? Symbol.keyFor(sym.$Tempo) as string; + const keyFor = this.config.store ?? Symbol.keyFor($Tempo) as string; const storage = proxify(Object.assign({ key: keyFor, scope: 'storage' }, omit(Tempo.readStore(keyFor), 'value'))); return Object.assign({}, this.default, storage, this.discovery, this.config); } @@ -874,7 +964,7 @@ export class Tempo implements TempoBrand { * configuration governing the static 'rules' used when parsing t.DateTime argument */ static get parse() { - const parse = Tempo.#global.parse; + const parse = (this as any)[$Internal]().parse; return secure({ ...omit(parse, 'token'), // spread primitives like {pivot} snippet: { ...parse.snippet }, // spread nested objects @@ -898,28 +988,28 @@ export class Tempo implements TempoBrand { /** static Tempo.ignores (registry) */ static get ignores(): Secure { - return secure(ownKeys(Tempo.#global.parse.ignore, true)); + return secure(ownKeys((this as any)[$Internal]().parse.ignore, true) as string[]); } /** allow instanceof to work across module boundaries via the local brand symbol */ - static [sym.$isTempo] = true; + static [$Identity] = true; static [Symbol.hasInstance](instance: any) { - return !!(instance?.[sym.$isTempo]) + return !!(instance?.[$Identity]) } /** check if a supplied variable is a valid Tempo instance */ static isTempo(instance?: any): instance is Tempo { - return !!(instance?.[sym.$isTempo]) + return !!(instance?.[$Identity]) } static { // Static initialization block to sequence the bootstrap phase // Define the reactive register hook - getRuntime().setHook(sym.$Register, (plugin: Plugin | Plugin[]) => { + getRuntime().setHook($Register, (plugin: Plugin | Plugin[]) => { if (!Tempo.isExtending) Tempo.extend(plugin) }); onRegistryReset(() => { - Tempo.#buildGuard(); + (Tempo as any)[$buildGuard](); }); Tempo.init(); // synchronously initialize the library @@ -937,12 +1027,12 @@ export class Tempo implements TempoBrand { /** current parsing depth to manage state isolation */ #parseDepth = 0; /** current mutation depth to manage infinite recursion */#mutateDepth = 0; /** instance values to complement static values */ #local = { - /** instance configuration */ config: { [lib.$Logify]: true } as unknown as Internal.Config, + /** instance configuration */ config: { [$Logify]: true } as unknown as Internal.Config, /** instance parse rules (only populated if provided) */ parse: { result: [] as Internal.MatchResult[] } as Internal.Parse } as Internal.State; /** @internal internal key for signaling pre-errored state in constructor */ - static [sym.$errored] = sym.$errored; + static [$errored] = $errored; /** @internal */ static [TermError](config: Internal.Config, term: string): void { const hint = Tempo.#terms.length === 0 ? ". (No term plugins are registered—did you forget to call Tempo.extend(TermsModule)?)" : ""; @@ -951,15 +1041,15 @@ export class Tempo implements TempoBrand { if (config.catch !== true) throw new Error(msg); } - /** @internal */ static get [sym.$dbg](): Logify { return Tempo.#dbg } - /** @internal */ static get [sym.$guard]() { return Tempo.#guard } + /** @internal */ static get [$dbg](): Logify { return Tempo.#dbg } + /** @internal */ static get [$guard]() { return Tempo.#guard } /** * @internal Internal access to instance private state. * This surface is not part of the public contract and is subject to change. */ - [sym.$Internal]() { - const self: Tempo = (this as any)[lib.$Target] ?? this; + [$Internal]() { + const self: Tempo = unwrap(this); return { get zdt() { return self.#zdt }, set zdt(val: any) { self.#zdt = val }, @@ -1001,7 +1091,6 @@ export class Tempo implements TempoBrand { return 'Tempo'; // hard-coded to avoid minification mangling } - get [sym.$isTempo](): true { return true } /** * Instantiates a new `Tempo` object with configuration only. @@ -1018,9 +1107,14 @@ export class Tempo implements TempoBrand { constructor(tempo: t.DateTime, options?: t.Options); constructor(tempo?: t.DateTime | t.Options, options: t.Options = {}) { this.#now = instant(); // stash current Instant - [this.#tempo, this.#options] = this.#swap(tempo, options); // swap arguments around + [this.#tempo, this.#options] = this.#swap(tempo, options);// swap arguments around + if (isZonedDateTime(this.#tempo)) this.#zdt = this.#tempo; this.#setLocal(this.#options); // parse local options + if (!this.#zdt && isObject(this.#tempo) && isDurationLike(this.#tempo)) { + // relative shorthand for "now plus duration" + this.#zdt = this.#now.toZonedDateTimeISO(this.#local.config.timeZone).add(this.#tempo as Temporal.DurationLike); + } const { mode } = this.#local.parse; const input = String(this.#tempo ?? ''); @@ -1037,13 +1131,13 @@ export class Tempo implements TempoBrand { this.#anchor = this.#options.anchor; // 🧬 Unified State Hand-off (from clone / mutate) - const handoff = (this.#options as any)[sym.$Internal]; + const handoff = (this.#options as any)[$Internal]; if (isObject(handoff)) { this.#errored = handoff.errored ?? false; this.#mutateDepth = handoff.mutateDepth ?? 0; this.#parseDepth = 0; this.#matches = handoff.matches; - } else if ((this.#options as any)[sym.$errored]) { + } else if ((this.#options as any)[$errored]) { this.#errored = true; } @@ -1139,7 +1233,7 @@ export class Tempo implements TempoBrand { #setDelegator(host: 'term' | 'fmt') { const target = Object.create(null); const proxy = delegate(target, (key) => { - if (key === lib.$Discover) return this.#discover(host, target); + if (key === $Discover) return this.#discover(host, target); if (!isString(key)) return; // discovery phase @@ -1156,7 +1250,7 @@ export class Tempo implements TempoBrand { try { const result = term.define.call(this, keyOnly); const res = Array.isArray(result) ? getTermRange(this, result, keyOnly) : result; - return (typeof res === 'object' && res !== null) ? secure(res) : res; + return isObject(res) ? secure(res) : res; } catch (err: any) { if (err.message.includes('Class constructor')) { Tempo.#dbg.warn(this.#local.config, `Misidentified class in term definition: ${key}`, err.stack ?? err); @@ -1189,7 +1283,7 @@ export class Tempo implements TempoBrand { try { const res = term.resolve ? term.resolve.call(this, anchor) : term.define.call(this, keyOnly, anchor); const out = (getTermRange(this, (Array.isArray(res) ? (res as any) : [res]), keyOnly, anchor) as any); - return (typeof out === 'object' && out !== null) ? secure(out) : out; + return isObject(out) ? secure(out) : out; } catch (err: any) { if (err.message.includes('Class constructor')) { Tempo.#dbg.warn(this.#local.config, `Misidentified class in term discovery: ${term.key}`, err.stack ?? err); @@ -1256,13 +1350,9 @@ export class Tempo implements TempoBrand { /** current Tempo configuration */ get config() { - const out = Object.assign({}, - Default, - Tempo.#global.config, - this.#local.config - ); - - markConfig(out); + const global = (this as any)[$Internal]().config; + const out = markConfig(Object.create(global)); + Object.entries(this.#local.config).forEach(([k, v]) => setProperty(out, k, v)); if (!Object.hasOwn(out, 'mode')) setProperty(out, 'mode', this.#local.parse.mode); if (!Object.hasOwn(out, 'lazy')) setProperty(out, 'lazy', this.#local.parse.lazy); @@ -1279,7 +1369,7 @@ export class Tempo implements TempoBrand { /** Instance-specific parse rules (merged with global) */ get parse(): Internal.Parse { - const self: Tempo = (this as any)[lib.$Target] ?? this; + const self: Tempo = unwrap(this); self.#resolve(); // Return a shadowed view so we can safely inject matches without breaking the freeze on the original state const out = Object.create(self.#local.parse); @@ -1338,13 +1428,14 @@ export class Tempo implements TempoBrand { /** setup local 'config' and 'parse' rules (prototype-linked to global) */ #setLocal(options: t.Options = {}) { - this.#local.config = markConfig(Object.create(Tempo.#global.config)); + const classState = (this.constructor as any)[$Internal](); + this.#local.config = markConfig(Object.create(classState.config)); Object.assign(this.#local.config, { scope: 'local' }); - this.#local.parse = markConfig(Object.create(Tempo.#global.parse)); + this.#local.parse = markConfig(Object.create(classState.parse)); setProperty(this.#local.parse, 'result', [...(options.result ?? [])]); - Tempo.#setConfig(this.#local, options); // set #local config + (this.constructor as any)[$setConfig](this.#local, options); // set #local config } /** parse DateTime input */ @@ -1404,18 +1495,13 @@ export class Tempo implements TempoBrand { /** check if we've been given a ZonedDateTimeLike object */ #isZonedDateTimeLike(tempo: t.DateTime | t.Options | undefined): tempo is Temporal.ZonedDateTimeLike & { value?: any } { - if (!isObject(tempo) || isEmpty(tempo)) - return false; + if (!isObject(tempo) || isEmpty(tempo) || isTempo(tempo)) return false; - // if it contains any 'options' keys, it's not a ZonedDateTime + // if it contains any 'options' keys (other than value), it's likely an Options object const keys = ownKeys(tempo); - if (keys.some(key => enums.OPTION.has(key) && key !== 'value')) - return false; + if (keys.some(key => enums.OPTION.has(key) && key !== 'value')) return false; - // we include {value} to allow for Tempo instances - return keys - .filter(isString) - .every((key: string) => enums.ZONED_DATE_TIME.has(key)) + return isZonedDateTimeLike(tempo); } #result(...rest: Partial[]) { diff --git a/packages/tempo/src/tempo.index.ts b/packages/tempo/src/tempo.index.ts index 5b6d2929..f400aba0 100644 --- a/packages/tempo/src/tempo.index.ts +++ b/packages/tempo/src/tempo.index.ts @@ -2,14 +2,15 @@ import { Tempo } from './tempo.class.js'; import { onRegistryReset, enums } from '#tempo/support'; import { ParseModule } from '#tempo/parse'; +import { FormatModule } from '#tempo/format'; + import { MutateModule } from '#tempo/mutate'; import { DurationModule } from '#tempo/duration'; -import { FormatModule } from '#tempo/format'; import { TermsModule } from '#tempo/term'; import { getRuntime } from '#tempo/support'; // Batteries Included: Register standard modules -const core = [ParseModule, MutateModule, FormatModule, DurationModule, TermsModule]; +const core = [ParseModule, FormatModule, MutateModule, DurationModule, TermsModule]; getRuntime().modules['Tempo'] = Tempo; onRegistryReset(() => { diff --git a/packages/tempo/src/tempo.type.ts b/packages/tempo/src/tempo.type.ts index e9e62fa5..a88a380e 100644 --- a/packages/tempo/src/tempo.type.ts +++ b/packages/tempo/src/tempo.type.ts @@ -7,11 +7,12 @@ * Inside `tempo.class.ts` these are accessed via `import * as t`. */ -import { sym } from '#tempo/support/tempo.symbol.js'; +import { sym, type TempoBrand } from '#tempo/support/tempo.symbol.js'; import * as enums from '#tempo/support/tempo.enum.js'; +import type { Logify } from '#library/logify.class.js'; import type { Snippet, Layout, Event, Period, Ignore } from '#tempo/support/tempo.default.js'; import type { IntRange, NonOptional, Property, Plural, Prettify, TemporalObject, TypeValue } from '#library/type.library.js'; -import type { Range, TermPlugin, ResolvedRange, Plugin, Terms, Module, Extension } from '#tempo/plugin/plugin.type.js'; +import type { TermPlugin, Plugin } from '#tempo/plugin/plugin.type.js'; import type { Token } from '#tempo/support/tempo.symbol.js'; import type { Tempo } from '#tempo/tempo.class.js'; @@ -28,7 +29,7 @@ declare global { } /** the value that Tempo will attempt to interpret as a valid ISO date / time */ -export type DateTime = string | number | bigint | Date | Tempo | TemporalObject | Temporal.ZonedDateTimeLike | undefined | null +export type DateTime = string | number | bigint | Date | Tempo | TempoBrand | TemporalObject | Temporal.ZonedDateTimeLike | undefined | null; export type Pattern = string | RegExp export type Logic = string | number | Function @@ -145,9 +146,9 @@ export namespace Internal { export interface BaseOptions { /** localStorage key */ store: string; /** globalThis Discovery Symbol */ discovery: string | symbol; - /** additional console.log for tracking */ debug: boolean | undefined; - /** catch or throw Errors */ catch: boolean | undefined; - /** suppress console output during catch */ silent: boolean | undefined; + /** additional console.log for tracking */ debug: Logify.Constructor["debug"]; + /** catch or throw Errors */ catch: Logify.Constructor["catch"]; + /** suppress console output during catch */ silent: Logify.Constructor["silent"]; /** Temporal timeZone */ timeZone: Temporal.TimeZoneLike; /** Temporal calendar */ calendar: Temporal.CalendarLike; /** locale (e.g. en-AU) */ locale: string; @@ -187,17 +188,20 @@ export namespace Internal { /** @internal valid Temporal units for ZonedDateTime */ ZONED_DATE_TIME: Set; /** @internal current recursion depth during parsing */ parseDepth?: number; - /** @internal current matches during parsing */ matches?: Match[] | undefined; + /** @internal current matches during parsing */ matches?: Match[]; /** @internal current anchor during parsing */ anchor?: Temporal.ZonedDateTime; + /** @internal current ZonedDateTime during parsing */ zdt?: Temporal.ZonedDateTime; /** @internal has the parse operation errored? */ errored?: boolean; } /** debug a Tempo instantiation */ export type MatchExtend = { type: 'Event' | 'Period', value: string | number | Function } + export type MatchSource = 'default' | 'global' | 'local' | `plugin:${string}` export type Match = { /** pattern which matched the input */ match?: string | undefined; /** groups from the pattern match */ groups?: Groups; /** was this a nested/anchored parse? */ isAnchored?: boolean; + /** where this match came from: 'default', 'global', 'local', or `plugin:${string}` */ source?: MatchSource; } & (TypeValue | MatchExtend) /** Debugging results of a parse operation. See `doc/tempo.api.md`. */ diff --git a/packages/tempo/src/tsconfig.json b/packages/tempo/src/tsconfig.json index a2846ca4..cdef1a92 100644 --- a/packages/tempo/src/tsconfig.json +++ b/packages/tempo/src/tsconfig.json @@ -17,8 +17,13 @@ "#tempo/parse": ["./discrete/discrete.parse.ts"], "#tempo/format": ["./discrete/discrete.format.ts"], "#tempo/discrete": ["./discrete/discrete.index.ts"], - "#tempo/duration": ["./plugin/module/module.duration.ts"], + "#tempo/duration": ["./module/module.duration.ts"], + "#tempo/mutate": ["./module/module.mutate.ts"], "#tempo/ticker": ["./plugin/extend/extend.ticker.ts"], + "#tempo/engine/*.js": ["./engine/*.ts"], + "#tempo/module/*.js": ["./module/*.ts"], + "#tempo/plugin/extend/*.js": ["./plugin/extend/*.ts"], + "#tempo/plugin/term/*.js": ["./plugin/term/*.ts"], "#tempo/term/*": ["./plugin/term/term.*.ts"], "#tempo/support": ["./support/support.index.ts"], "#tempo/support/*": ["./support/*"], diff --git a/packages/tempo/test/compact.time.test.ts b/packages/tempo/test/compact.time.test.ts new file mode 100644 index 00000000..b22f1d04 --- /dev/null +++ b/packages/tempo/test/compact.time.test.ts @@ -0,0 +1,55 @@ +import { Tempo } from '#tempo'; + +describe('compact hhmiss parsing', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('parses 093015 as 09:30:15', () => { + const t = new Tempo('093015', { timeZone: 'UTC' }); + + expect(t.hh).toBe(9); + expect(t.mi).toBe(30); + expect(t.ss).toBe(15); + expect(t.parse.result?.[0]?.match).toBe('hourMinuteSecond'); + }); + + test('parses 235959 as 23:59:59', () => { + const t = new Tempo('235959', { timeZone: 'UTC' }); + + expect(t.hh).toBe(23); + expect(t.mi).toBe(59); + expect(t.ss).toBe(59); + expect(t.parse.result?.[0]?.match).toBe('hourMinuteSecond'); + }); + + test('supports 240000 rollover to next day at 00:00:00', () => { + const anchor = new Tempo('2026-04-01T12:00:00', { timeZone: 'UTC' }); + const t = new Tempo('240000', { timeZone: 'UTC', anchor: anchor.toDateTime() }); + + expect(t.hh).toBe(0); + expect(t.mi).toBe(0); + expect(t.ss).toBe(0); + expect(t.dd).toBe(2); + expect(t.parse.result?.[0]?.match).toBe('hourMinuteSecond'); + }); + + test('keeps valid hhmiss ahead of compact 6-digit date layouts', () => { + const t = new Tempo('110559', { timeZone: 'Europe/London' }); + + expect(t.hh).toBe(11); + expect(t.mi).toBe(5); + expect(t.ss).toBe(59); + expect(t.parse.result?.[0]?.match).toBe('hourMinuteSecond'); + }); + + test('does not regress compact date parsing for 8-digit input', () => { + const us = new Tempo('04012026', { timeZone: 'America/New_York' }); + const uk = new Tempo('04012026', { timeZone: 'Europe/London' }); + + expect(us.mm).toBe(4); + expect(us.dd).toBe(1); + expect(uk.mm).toBe(1); + expect(uk.dd).toBe(4); + }); +}); diff --git a/packages/tempo/test/constructor.shorthand.test.ts b/packages/tempo/test/constructor.shorthand.test.ts new file mode 100644 index 00000000..88d4067d --- /dev/null +++ b/packages/tempo/test/constructor.shorthand.test.ts @@ -0,0 +1,37 @@ +import { Tempo } from '#tempo/core'; +import '#tempo/parse'; +import '#tempo/format'; + +describe('Tempo Shorthand Constructor', () => { + beforeEach(() => { + Tempo.init(); + }); + + it('should support DurationLike objects as shorthand for "now plus duration"', () => { + const t = new Tempo({ hours: 2 }); + + // Since we can't easily mock the internal 'instant()' call in the constructor + // without deeper integration, we'll verify it's approximately 2 hours from now. + const now = Temporal.Now.zonedDateTimeISO('UTC'); + const expected = now.add({ hours: 2 }); + + // Allow for 1 second of drift during test execution + const diff = t.toDateTime().epochMilliseconds - expected.epochMilliseconds; + expect(Math.abs(diff)).toBeLessThanOrEqual(1000); + }); + + it('should support negative durations as shorthand for "now minus duration"', () => { + const t = new Tempo({ days: -1 }); + const now = Temporal.Now.zonedDateTimeISO('UTC'); + const expected = now.subtract({ days: 1 }); + + const diff = t.toDateTime().epochMilliseconds - expected.epochMilliseconds; + expect(Math.abs(diff)).toBeLessThanOrEqual(1000); + }); + + it('should still support standard date strings along with shorthand options', () => { + const t = new Tempo('2024-05-20', { sphere: 'south' }); + expect(t.format('{yyyy}-{mm}-{dd}')).toBe('2024-05-20'); + expect(t.config.sphere).toBe('south'); + }); +}); diff --git a/packages/tempo/test/discovery_security.test.ts b/packages/tempo/test/discovery_security.test.ts index ea975f5d..56392a69 100644 --- a/packages/tempo/test/discovery_security.test.ts +++ b/packages/tempo/test/discovery_security.test.ts @@ -1,6 +1,6 @@ import { Tempo } from '#tempo'; import { registryUpdate } from '#tempo/support'; -import lib from '#library/symbol.library.js'; +import { unwrap } from '#library/primitive.library.js'; describe('Discovery Security (Direct Registry Check)', () => { @@ -42,17 +42,17 @@ describe('Discovery Security (Direct Registry Check)', () => { afterAll(() => { // Cleanup added keys to keep environment clean for other tests - const numTarget = (Tempo.NUMBER as any)[lib.$Target]; + const numTarget = unwrap(Tempo.NUMBER as any); if (numTarget) { delete numTarget.eleven; } - const fmtTarget = (Tempo.FORMAT as any)[lib.$Target]; + const fmtTarget = unwrap(Tempo.FORMAT as any); if (fmtTarget) { delete fmtTarget.custom; } - const tzTarget = (Tempo.TIMEZONE as any)[lib.$Target]; + const tzTarget = unwrap(Tempo.TIMEZONE as any); if (tzTarget) { delete tzTarget.myzone; } diff --git a/packages/tempo/test/engine.layout.test.ts b/packages/tempo/test/engine.layout.test.ts new file mode 100644 index 00000000..72db5cd6 --- /dev/null +++ b/packages/tempo/test/engine.layout.test.ts @@ -0,0 +1,97 @@ +import { + DEFAULT_LAYOUT_CLASS, + createLayoutController, + resolveLayoutClassificationOrder, + resolveLayoutOrder, +} from '#tempo/engine/engine.layout.js'; + +const makeLayout = (names: string[]) => + Object.fromEntries(names.map(name => [Symbol(name), name])) as Record; + +const orderOf = (layout: Record) => + Reflect.ownKeys(layout).map(key => (key as symbol).description); + +describe('engine.layout resolver', () => { + test('no-op when no swap pair matches', () => { + const layout = makeLayout(['x', 'y', 'z']); + const resolved = resolveLayoutOrder({ + layout, + mdyLayouts: [['dmy', 'mdy']], + isMonthDay: true, + }); + + expect(resolved).toBe(layout); + expect(orderOf(resolved)).toEqual(['x', 'y', 'z']); + }); + + test('swaps matching pair in month-day locales', () => { + const layout = makeLayout(['dmy', 'mdy', 'x']); + const resolved = resolveLayoutOrder({ + layout, + mdyLayouts: [['dmy', 'mdy']], + isMonthDay: true, + }); + + expect(orderOf(resolved)).toEqual(['mdy', 'dmy', 'x']); + }); + + test('swaps matching pair in reverse for non-month-day locales', () => { + const layout = makeLayout(['mdy', 'dmy', 'x']); + const resolved = resolveLayoutOrder({ + layout, + mdyLayouts: [['dmy', 'mdy']], + isMonthDay: false, + }); + + expect(orderOf(resolved)).toEqual(['dmy', 'mdy', 'x']); + }); + + test('handles multiple swap pairs deterministically', () => { + const layout = makeLayout(['dmyA', 'mdyA', 'x', 'dmyB', 'mdyB', 'y']); + const resolved = resolveLayoutOrder({ + layout, + mdyLayouts: [['dmyA', 'mdyA'], ['dmyB', 'mdyB']], + isMonthDay: true, + }); + + expect(orderOf(resolved)).toEqual(['mdyA', 'dmyA', 'x', 'mdyB', 'dmyB', 'y']); + }); + + test('preserves relative order of unrelated layouts', () => { + const layout = makeLayout(['before', 'dmy', 'mdy', 'after']); + const resolved = resolveLayoutOrder({ + layout, + mdyLayouts: [['dmy', 'mdy']], + isMonthDay: true, + }); + + expect(orderOf(resolved)).toEqual(['before', 'mdy', 'dmy', 'after']); + }); + + test('creates a minimum controller map with one default entry', () => { + const layout = makeLayout(['hms', 'dmy6', 'mdy6', 'ymd6']); + const controller = createLayoutController(layout); + + expect(Reflect.ownKeys(controller)).toEqual([DEFAULT_LAYOUT_CLASS]); + expect(controller[DEFAULT_LAYOUT_CLASS]).toEqual(['hms', 'dmy6', 'mdy6', 'ymd6']); + }); + + test('uses controller classification order when provided', () => { + const layout = makeLayout(['hms', 'dmy6', 'mdy6', 'ymd6', 'custom']); + const classified = resolveLayoutClassificationOrder(layout, { + [DEFAULT_LAYOUT_CLASS]: ['hms', 'ymd6', 'dmy6', 'mdy6'], + }, DEFAULT_LAYOUT_CLASS); + + expect(orderOf(classified)).toEqual(['hms', 'ymd6', 'dmy6', 'mdy6', 'custom']); + }); + + test('falls back to original order when classification is missing', () => { + const layout = makeLayout(['hms', 'dmy6', 'mdy6', 'ymd6']); + const classified = resolveLayoutClassificationOrder(layout, { + other: ['ymd6', 'mdy6'], + }, DEFAULT_LAYOUT_CLASS); + + expect(classified).toBe(layout); + expect(orderOf(classified)).toEqual(['hms', 'dmy6', 'mdy6', 'ymd6']); + }); +}); diff --git a/packages/tempo/test/instance.since.rtf.test.ts b/packages/tempo/test/instance.since.rtf.test.ts index cc3bb16b..6d569535 100644 --- a/packages/tempo/test/instance.since.rtf.test.ts +++ b/packages/tempo/test/instance.since.rtf.test.ts @@ -41,4 +41,13 @@ describe('instance.since relative formatting', () => { const res = t.since(t1, 'hours'); expect(res).toMatch(/2 hours ago/i); }); + + test('inherits rtfFormat from instance configuration', () => { + const t1 = new Tempo('2024-01-01T12:00:00'); + const rtf = new Intl.RelativeTimeFormat('fr', { style: 'long' }); + const t = new Tempo('2024-01-01T14:30:00', { rtfFormat: rtf }); + + const res = t.since(t1, 'hours'); + expect(res).toMatch(/il y a 2 heures/i); + }); }); diff --git a/packages/tempo/test/layout.order.test.ts b/packages/tempo/test/layout.order.test.ts new file mode 100644 index 00000000..19c1d87e --- /dev/null +++ b/packages/tempo/test/layout.order.test.ts @@ -0,0 +1,113 @@ +import { Tempo } from '#tempo'; + +describe('layout matching order', () => { + beforeEach(() => { + Tempo.init(); + }); + + test('uses month-day-year first for US timezone on compact 8-digit input', () => { + const t = new Tempo('04012026', { timeZone: 'America/New_York' }); + const first = t.parse.result?.[0]; + + expect(t.yy).toBe(2026); + expect(t.mm).toBe(4); + expect(t.dd).toBe(1); + expect(first?.match).toBe('date'); + expect(first?.groups?.yy).toBe('2026'); + expect(first?.groups?.mm).toBe('04'); + expect(first?.groups?.dd).toBe('01'); + expect(first?.groups?.nbr).toBeUndefined(); + }); + + test('uses day-month-year first for UK timezone on compact 8-digit input', () => { + const t = new Tempo('04012026', { timeZone: 'Europe/London' }); + const first = t.parse.result?.[0]; + + expect(t.yy).toBe(2026); + expect(t.mm).toBe(1); + expect(t.dd).toBe(4); + expect(first?.match).toBe('date'); + expect(first?.groups?.yy).toBe('2026'); + expect(first?.groups?.mm).toBe('01'); + expect(first?.groups?.dd).toBe('04'); + expect(first?.groups?.nbr).toBeUndefined(); + }); + + test('falls back to compact 6-digit date when input is not a valid hhmiss time', () => { + const t = new Tempo('310559', { timeZone: 'Europe/London' }); + const first = t.parse.result?.[0]; + + expect(t.yy).toBe(1959); + expect(t.mm).toBe(5); + expect(t.dd).toBe(31); + expect(first?.match).toBe('dayMonthYearShort'); + expect(first?.groups?.yy).toBe('59'); + expect(first?.groups?.mm).toBe('05'); + expect(first?.groups?.dd).toBe('31'); + }); + + test('falls back to the only valid compact 6-digit date layout even in month-day locales', () => { + const t = new Tempo('310559', { timeZone: 'America/New_York' }); + const first = t.parse.result?.[0]; + + expect(t.yy).toBe(1959); + expect(t.mm).toBe(5); + expect(t.dd).toBe(31); + expect(first?.match).toBe('dayMonthYearShort'); + }); + + test('keeps slash-separated ambiguous date aligned with timezone preference', () => { + const us = new Tempo('04/01/2026', { timeZone: 'America/New_York' }); + const uk = new Tempo('04/01/2026', { timeZone: 'Europe/London' }); + + expect(us.mm).toBe(4); + expect(us.dd).toBe(1); + expect(us.parse.result?.[0]?.match).toBe('date'); + expect(us.parse.result?.[0]?.groups?.mm).toBe('04'); + expect(us.parse.result?.[0]?.groups?.dd).toBe('01'); + + expect(uk.mm).toBe(1); + expect(uk.dd).toBe(4); + expect(uk.parse.result?.[0]?.match).toBe('date'); + expect(uk.parse.result?.[0]?.groups?.mm).toBe('01'); + expect(uk.parse.result?.[0]?.groups?.dd).toBe('04'); + }); + + test('still resolves explicit relative expression via relativeOffset layout', () => { + const t = new Tempo('2 days ago', { timeZone: 'UTC' }); + const first = t.parse.result?.[0]; + + expect(first?.match).toBe('relativeOffset'); + }); + + test('keeps current precedence: single-digit numeric input resolves as time before offset', () => { + const t = new Tempo('6', { timeZone: 'UTC' }); + const first = t.parse.result?.[0]; + + expect(t.isValid).toBe(true); + expect(first?.match).toBe('time'); + expect(t.hh).toBe(6); + expect(t.mi).toBe(0); + }); + + test('still allows explicit day-offset syntax to resolve via offset layout', () => { + const t = new Tempo('+6', { timeZone: 'UTC' }); + const first = t.parse.result?.[0]; + + expect(t.isValid).toBe(true); + expect(first?.match).toBe('offset'); + }); + + test('parses compact yymmdd (ymd6) correctly regardless of locale', () => { + const us = new Tempo('590531', { timeZone: 'America/New_York' }); + const uk = new Tempo('590531', { timeZone: 'Europe/London' }); + + // ymd6 is unambiguous — timezone should not change the interpretation + for (const t of [us, uk]) { + expect(t.yy).toBe(1959); + expect(t.mm).toBe(5); + expect(t.dd).toBe(31); + expect(t.parse.result?.[0]?.match).toBe('yearMonthDayShort'); + } + }); +}); diff --git a/packages/tempo/test/pattern.weekday.test.ts b/packages/tempo/test/pattern.weekday.test.ts index 920c0919..ea5cfc77 100644 --- a/packages/tempo/test/pattern.weekday.test.ts +++ b/packages/tempo/test/pattern.weekday.test.ts @@ -40,4 +40,38 @@ describe(`${label}`, () => { expect(tempo.fmt.yearMonthDay) .toBe(formatDate(date)); }) + + test(`${label} test pattern {weekday}, >Wed (next Wednesday)`, () => { + const tempo = new Tempo('>Wed'); + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current >= Wed) ? 1 : 0; + date.setDate(date.getDate() - current + Wed + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) + + test(`${label} test pattern {weekday}, >=Wed (this or next Wednesday)`, () => { + const tempo = new Tempo('>=Wed'); + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current > Wed) ? 1 : 0; + date.setDate(date.getDate() - current + Wed + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) + + test(`${label} test pattern {weekday}, <2Thu (second Thursday before today)`, () => { + const tempo = new Tempo('<2Thu'); + const Thu = 4; + const date = new Date(); + const current = date.getDay() || Sun; + const adjust = (current <= Thu) ? -2 : -1; + date.setDate(date.getDate() - current + Thu + (adjust * 7)); + + expect(tempo.parse.result?.[0].match).toBe('weekDay'); + expect(tempo.fmt.yearMonthDay).toBe(formatDate(date)); + }) }) \ No newline at end of file diff --git a/packages/tempo/test/runtime_brand.test.ts b/packages/tempo/test/runtime_brand.test.ts index c85e56ce..efd0ce93 100644 --- a/packages/tempo/test/runtime_brand.test.ts +++ b/packages/tempo/test/runtime_brand.test.ts @@ -40,7 +40,6 @@ describe('TempoRuntime Cross-Bundle Adoption', () => { expect(rt).toBe(original); return; } - console.warn('Skipping mock adoption test: globalThis bridge is already locked by a different runtime instance.'); return; } diff --git a/packages/tempo/test/sandbox-factory.test.ts b/packages/tempo/test/sandbox-factory.test.ts new file mode 100644 index 00000000..19be8109 --- /dev/null +++ b/packages/tempo/test/sandbox-factory.test.ts @@ -0,0 +1,100 @@ +import { Tempo } from '#tempo'; + +describe('Sandbox Factory Pattern', () => { + it('should return a subclass when create is called with options', () => { + const Sandbox = Tempo.create({ locale: 'en-GB' }); + expect(Sandbox).not.toBe(Tempo); + expect(Object.getPrototypeOf(Sandbox)).toBe(Tempo); + expect(Sandbox.name).toBe('SandboxTempo'); + }); + + it('should maintain isolated registries for sandboxes', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const MyTempo = Tempo.create({ + period: { + 'tea-time': '16:00' + } + }); + + // The base Tempo should not have 'tea-time' + expect(() => new Tempo('tea-time')).toThrow(); + spy.mockRestore(); + + const t = new MyTempo('tea-time'); + expect(t.hh).toBe(16); + expect(t.mi).toBe(0); + }); + + it('should support shadowing global aliases', () => { + // Global 'noon' is 12:00 + const EarlyNoon = Tempo.create({ + period: { + 'noon': '11:00' + } + }); + + // Original remains unaffected (if not manually reset in a way that changes it) + // We expect 12:00 for the base Tempo + const t1 = new Tempo('noon'); + expect(t1.hh).toBe(12); + + const t2 = new EarlyNoon('noon'); + expect(t2.hh).toBe(11); + }); + + it('should record traceability info in parse results', () => { + const MyTempo = Tempo.create({ + period: { + 'half-hour': function (this: Tempo) { return `${this.hh}:30` } + } + }); + + const t = new MyTempo('half-hour'); + const results = t.parse.result; + + // Find the Period match + const match = results.find(r => r.type === 'Period'); + expect(match).toBeDefined(); + expect(match?.source).toBe('local'); // 'local' relative to the Sandbox + }); + + it('should allow instance-specific overrides to shadow sandbox aliases', () => { + const MyTempo = Tempo.create({ + period: { + 'break': '10:00' + } + }); + + // Sandwich break at 11:00 for this specific instance + const t = new MyTempo('break', { + period: { + 'break': '11:00' + } + }); + + expect(t.hh).toBe(11); + + const match = t.parse.result.find(r => r.type === 'Period' && r.value === 'break'); + expect(match?.source).toBe('local'); + }); + + it('should guarantee immutability of the Sandbox class', () => { + const Sandbox = Tempo.create({ locale: 'fr-FR' }); + + // Attempting to modify static property should fail (if @Immutable is working) + try { + (Sandbox as any).someNewProp = 'test'; + } catch (e) { } + + expect((Sandbox as any).someNewProp).toBeUndefined(); + }); + + it('should share terms across sandboxes by default', () => { + // Terms are currently stored in the singleton Runtime pluginsDb + // This is expected behavior as plugins are "library level" extensions + const Sandbox1 = Tempo.create({}); + const Sandbox2 = Tempo.create({}); + + expect(Sandbox1.terms).toEqual(Sandbox2.terms); + }); +}); diff --git a/packages/tempo/test/symbol-import.test.ts b/packages/tempo/test/symbol-import.test.ts index 4092e86b..7d280633 100644 --- a/packages/tempo/test/symbol-import.test.ts +++ b/packages/tempo/test/symbol-import.test.ts @@ -1,5 +1,5 @@ -import lib from '#library/symbol.library.js' +import { sym } from '#library/symbol.library.js' test('symbol import', () => { - expect(lib.$Logify).toBeDefined() + expect(sym.$Logify).toBeDefined() }) diff --git a/packages/tempo/test/term_unified.test.ts b/packages/tempo/test/term_unified.test.ts index 78e1fbd4..7926143f 100644 --- a/packages/tempo/test/term_unified.test.ts +++ b/packages/tempo/test/term_unified.test.ts @@ -88,6 +88,25 @@ describe('Term Unified Logic (Mutation & Identity)', () => { expect(t.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); }); + it('should support granular sphere matching (e.g. SouthSouthWest)', () => { + // Even though ranges are defined for 'south', 'SouthSouthWest' should match it via .includes() + const t = new Tempo(testDate, { catch: true, sphere: 'SouthSouthWest' }); + expect(t.format('{#qtr}')).toBe('Q4'); + expect(t.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); + }); + + it('should match sphere values case-insensitively', () => { + // Verify that all-caps 'SOUTH' produces the same results as lowercase 'south' + const t = new Tempo(testDate, { catch: true, sphere: 'SOUTH' }); + expect(t.format('{#qtr}')).toBe('Q4'); + expect(t.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); + + // Also verify all-caps granular matching 'SOUTHSOUTHWEST' works like 'SouthSouthWest' + const t2 = new Tempo(testDate, { catch: true, sphere: 'SOUTHSOUTHWEST' }); + expect(t2.format('{#qtr}')).toBe('Q4'); + expect(t2.set({ start: '#quarter' }).format('{yyyy}-{mm}-{dd}')).toBe('2024-04-01'); + }); + describe('Term Range Boundaries (Fluent & Immutable)', () => { it('should return start and end as Tempo instances', () => { const t = new Tempo(testDate, { catch: true, sphere: 'north' }); diff --git a/packages/tempo/test/tsconfig.json b/packages/tempo/test/tsconfig.json index f527c384..e6b4429b 100644 --- a/packages/tempo/test/tsconfig.json +++ b/packages/tempo/test/tsconfig.json @@ -29,7 +29,10 @@ "../src/core.index.ts" ], "#tempo/duration": [ - "../src/plugin/module/module.duration.ts" + "../src/module/module.duration.ts" + ], + "#tempo/mutate": [ + "../src/module/module.mutate.ts" ], "#tempo/format": [ "../src/discrete/discrete.format.ts" @@ -43,6 +46,18 @@ "#tempo/ticker": [ "../src/plugin/extend/extend.ticker.ts" ], + "#tempo/engine/*.js": [ + "../src/engine/*.ts" + ], + "#tempo/module/*.js": [ + "../src/module/*.ts" + ], + "#tempo/plugin/extend/*.js": [ + "../src/plugin/extend/*.ts" + ], + "#tempo/plugin/term/*.js": [ + "../src/plugin/term/*.ts" + ], "#tempo/term/*": [ "../src/plugin/term/term.*.ts" ], diff --git a/packages/tempo/vitest.config.ts b/packages/tempo/vitest.config.ts index f1b5c44a..70445db3 100644 --- a/packages/tempo/vitest.config.ts +++ b/packages/tempo/vitest.config.ts @@ -24,16 +24,17 @@ export default defineConfig({ { find: /^#tempo\/core$/, replacement: resolve(__dirname, './dist/core.index.js') }, { find: /^#tempo\/term$/, replacement: resolve(__dirname, './dist/plugin/term/term.index.js') }, { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './dist/plugin/term/standard.index.js') }, - { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/plugin/module/module.duration.js') }, + { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './dist/module/module.duration.js') }, { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './dist/discrete/discrete.$1.js') }, { find: /^#tempo\/discrete$/, replacement: resolve(__dirname, './dist/discrete/discrete.index.js') }, - { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/plugin/module/module.mutate.js') }, + { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './dist/module/module.mutate.js') }, { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.ticker.js') }, { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.js') }, { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/plugin.$1.js') }, - { find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/extend.$1.js') }, - { find: /^#tempo\/plugin\/module\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/module/module.$1.js') }, - { find: /^#tempo\/plugin\/term\.(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/term.$1.js') }, + { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/extend/$1.js') }, + { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './dist/engine/$1.js') }, + { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './dist/module/$1.js') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './dist/plugin/term/$1.js') }, { find: /^#tempo\/support$/, replacement: resolve(__dirname, './dist/support/support.index.js') }, { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './dist/$1.js') }, { find: /^#tempo$/, replacement: resolve(__dirname, './dist/tempo.index.js') }, @@ -45,15 +46,16 @@ export default defineConfig({ { find: /^#tempo\/term\/standard$/, replacement: resolve(__dirname, './src/plugin/term/standard.index.ts') }, { find: /^#tempo\/term\/(.*)$/, replacement: resolve(__dirname, './src/plugin/term/$1') }, { find: /^#tempo\/ticker$/, replacement: resolve(__dirname, './src/plugin/extend/extend.ticker.ts') }, - { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/plugin/module/module.duration.ts') }, + { find: /^#tempo\/duration$/, replacement: resolve(__dirname, './src/module/module.duration.ts') }, { find: /^#tempo\/(parse|format)$/, replacement: resolve(__dirname, './src/discrete/discrete.$1.ts') }, { find: /^#tempo\/discrete$/, replacement: resolve(__dirname, './src/discrete/discrete.index.ts') }, - { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/plugin/module/module.mutate.ts') }, + { find: /^#tempo\/mutate$/, replacement: resolve(__dirname, './src/module/module.mutate.ts') }, { find: /^#tempo\/scripts\/(.*)\.js$/, replacement: resolve(__dirname, './scripts/$1.ts') }, { find: /^#tempo\/plugin\/plugin\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/plugin.$1.ts') }, - { find: /^#tempo\/plugin\/extend\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/extend.$1.ts') }, - { find: /^#tempo\/plugin\/module\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/module/module.$1.ts') }, - { find: /^#tempo\/plugin\/term\.(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/term.$1.ts') }, + { find: /^#tempo\/plugin\/extend\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/extend/$1.ts') }, + { find: /^#tempo\/engine\/(.*)\.js$/, replacement: resolve(__dirname, './src/engine/$1.ts') }, + { find: /^#tempo\/module\/(.*)\.js$/, replacement: resolve(__dirname, './src/module/$1.ts') }, + { find: /^#tempo\/plugin\/term\/(.*)\.js$/, replacement: resolve(__dirname, './src/plugin/term/$1.ts') }, { find: /^#tempo\/support$/, replacement: resolve(__dirname, './src/support/support.index.ts') }, { find: /^#tempo\/(.*)\.js$/, replacement: resolve(__dirname, './src/$1.ts') }, { find: /^#tempo$/, replacement: resolve(__dirname, './src/tempo.index.ts') }, diff --git a/vitest.config.ts b/vitest.config.ts index e7ce1f6c..799ee325 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ test: { globals: true, environment: 'node', + setupFiles: [path.resolve(__dirname, './packages/tempo/bin/temporal-polyfill.ts')], alias: [ { find: /^#library\/(browser|server|common)\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/$1/$2.ts') }, { find: /^#library\/(.*)\.js$/, replacement: path.resolve(__dirname, './packages/library/src/common/$1.ts') },