Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tempo-monorepo",
"version": "3.2.1",
"version": "3.2.2",
"private": true,
"engines": {
"node": ">=20.0.0"
Expand Down Expand Up @@ -60,4 +60,4 @@
"esbuild@0.28.1": true,
"esbuild@0.21.5": true
}
}
}
4 changes: 2 additions & 2 deletions packages/library/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/library",
"version": "3.2.1",
"version": "3.2.2",
"description": "Shared utility library for Tempo",
"author": "Magma Computing Solutions",
"license": "MIT",
Expand Down Expand Up @@ -99,4 +99,4 @@
"optionalDependencies": {
"@js-temporal/polyfill": "^0.5.1"
}
}
}
56 changes: 55 additions & 1 deletion packages/library/src/common/international.library.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getOffsets } from '#library/temporal.library.js';
import { memoizeFunction } from '#library/function.library.js';
import { isFunction } from '#library/assertion.library.js';
import { isFunction, isDefined } from '#library/assertion.library.js';

/** memoized helper for Intl.RelativeTimeFormat instances */
const getRTF = memoizeFunction((locale?: string, style: Intl.RelativeTimeFormatStyle = 'narrow') => {
Expand Down Expand Up @@ -131,3 +131,57 @@ export function getHemisphere(timeZone: string = getDateTimeFormat().timeZone) {
return undefined;
}
}

type input = {
toPlainDate?: () => any,
year: number, month: number, day: number, dayOfWeek: number,
weekOfYear?: number | undefined, yearOfWeek?: number | undefined
}
type result = { weekOfYear: number, yearOfWeek: number };
/**
* Polyfill fallback for ISO 8601 Week of Year and Year of Week.
*
* Introduced because highly experimental native browser implementations of the Temporal API
* (e.g., Chrome/Firefox behind flags) currently return `undefined` for `weekOfYear` and `yearOfWeek`
* on ZonedDateTime objects. The TC39 spec moved toward calendar-dependent definitions,
* causing divergence between the @js-temporal/polyfill (which returns numbers) and native browsers (which return undefined).
*/
export function getISOWeekOfYear(zdt: input): result {
if (isDefined(zdt.weekOfYear) && isDefined(zdt.yearOfWeek))
return { weekOfYear: zdt.weekOfYear, yearOfWeek: zdt.yearOfWeek };

// Since Temporal.ZonedDateTime is passed in, we can safely extract the PlainDate
// to avoid crossing daylight saving boundaries when adding/subtracting days.
// Normalize to ISO 8601 calendar because properties like dayOfYear/dayOfWeek are calendar-dependent.
const pd = (isFunction(zdt.toPlainDate) ? zdt.toPlainDate() : Temporal.PlainDate.from(zdt)).withCalendar('iso8601');

// ISO week date algorithm: weeks start on Monday, and the first week of the year contains the first Thursday.
// Find the nearest Thursday to the current date.
const shift = 4 - pd.dayOfWeek;
const nearestThursday = shift >= 0 ? pd.add({ days: shift }) : pd.subtract({ days: -shift });

// The calendar year of that nearest Thursday is the ISO week-numbering year
const yearOfWeek = nearestThursday.year;

// The week number is exactly the nearest Thursday's dayOfYear divided by 7
const weekOfYear = Math.ceil(nearestThursday.dayOfYear / 7);

return { weekOfYear, yearOfWeek };
}

/**
* Probe the runtime to see if the locale defaults to Month-Day-Year order.
* @example
* probeMDY('en-US') // true
* probeMDY('en-GB') // false
*/
export function probeMDY(locale: string): boolean {
try {
// Use Dec 24th to check if '12' comes first
const date = new Date(2024, 11, 24);
const parts = new Intl.DateTimeFormat(locale).formatToParts(date);
return parts[0].type === 'month' && parts[0].value === '12';
} catch {
return false;
}
}
19 changes: 19 additions & 0 deletions packages/library/src/common/object.library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ export const pluck = <T, K extends keyof T>(objs: T[], key: K): T[K][] =>
export const extend = <T extends {}, U>(obj: T, ...objs: U[]) =>
Object.assign(obj, ...objs) as T;

/** recursively deep-merge objects */
export const deepMerge = <T extends Record<PropertyKey, any>>(...objects: Partial<T>[]): T => {
return objects.reduce((prev, obj) => {
if (!isObject(obj)) return prev;

Object.entries(obj).forEach(([key, value]) => {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
const pVal = prev[key];
if (isObject(pVal) && isObject(value)) {
prev[key as keyof T] = deepMerge(pVal, value) as any;
} else {
prev[key as keyof T] = value as any;
}
Comment thread
magmacomputing marked this conversation as resolved.
});

return prev;
}, {} as any) as T;
}

export const countProperties = (obj = {}) =>
ownKeys(obj).length

Expand Down
7 changes: 5 additions & 2 deletions packages/library/src/common/utility.library.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ownValues } from '#library/primitive.library.js';
import { ownEntries } from '#library/primitive.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';
Expand Down Expand Up @@ -100,7 +100,10 @@ export function deepFreeze<const T extends object>(obj: T, options?: { skip?: We

seen.add(obj);

ownValues(obj as any).forEach(val => deepFreeze(val, { skip }, seen));
ownEntries(obj as any).forEach(([key, val]) => {
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype')
deepFreeze(val, { skip }, seen);
});

return Object.freeze(obj) as Secure<T>;
}
15 changes: 15 additions & 0 deletions packages/tempo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.2.2] - 2026-06-18

### Added
- **New Format Tokens**: Added support for 6-digit compact date formatting tokens (`{dmy6}`, `{mdy6}`, `{ymd6}`) and short-year bounds (`{yywy}`, `{yyww}`).

### Changed
- **Intl Configuration**: Completely overhauled the `options.intl` resolution pipeline. Replaced brittle shallow-spread assignment with a robust, recursive `deepMerge` utility to prevent nested configuration clobbering (e.g. `dateTimeFormat` vs `relativeTimeFormat`).
- **ISO Week Renaming**: Renamed the `{ww}` token to `{wy}` to provide better semantic alignment with week-of-year calculations and resolve ambiguity with structural tokens.

### Fixed
- **Configuration Security**: Hardened recursive object manipulation utilities (`deepMerge` and `deepFreeze`) against Prototype Mutation and Prototype Freezing Denial of Service (DoS) vulnerabilities by strictly guarding against `__proto__`, `constructor`, and `prototype` keys during object traversals.

### Removed
- **Redundant Format Tokens**: Removed uppercase format tokens (`{MER}`, `{HH}`, `{DAY}`, `{WW}`, `{MM}`) from the engine to strictly enforce the token modifier pattern (e.g., `{mer:upper}`, `{h24}`, `{dd:ord}`). This eliminates ambiguity and ensures all output routes securely through the `Intl` localization engine.

Comment thread
magmacomputing marked this conversation as resolved.
## [3.2.1] - 2026-06-17

### Added
Expand Down
10 changes: 7 additions & 3 deletions packages/tempo/doc/tempo.format.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,17 +87,18 @@ Tempo.extend(FormatModule);
| :--- | :--- | :--- |
| `{yyyy}` | 4-digit Year | `2026` |
| `{yy}` | 2-digit Year | `26` |
| `{yywy}` | ISO Year & Week | `202617` |
| `{yw}` | ISO Year of Week | `2026` |
| `{yyww}` | ISO Year & Week | `202617` |
| `{wy}` | Zero-padded ISO Week of Year | `43` |
| `{mon}` | Full Month Name | `October` |
| `{mmm}` | Short Month Name | `Oct` |
| `{mm}` | Zero-padded Month | `10` |
| `{dd}` | Zero-padded Day | `24` |
| `{wkd}` | Full Weekday Name | `Saturday` |
| `{www}` | Short Weekday Name | `Sat` |
| `{dow}` | ISO Day of Week (1=Mon, 7=Sun) | `6` |
| `{ww}` | Zero-padded ISO Week of Year | `43` |
| `{hh}` | Zero-padded Hour (24h) | `15` |
| `{h24}` | Zero-padded Hour synonym (24h) | `15` |
| `{h12}` | Zero-padded Hour (12h) plus meridiem | `03pm` |
| `{mer}` | am/pm meridiem marker | `pm` |
| `{mi}` | Zero-padded Minutes | `30` |
Expand All @@ -110,6 +111,9 @@ Tempo.extend(FormatModule);
| `{dmy}` | Compact Date (ddmmyyyy) | `24102026` |
| `{mdy}` | Compact Date (mmddyyyy) | `10242026` |
| `{ymd}` | Compact Date (yyyymmdd) | `20261024` |
| `{dmy6}` | Compact 6-digit Date (ddmmyy) | `241026` |
| `{mdy6}` | Compact 6-digit Date (mmddyy) | `102426` |
| `{ymd6}` | Compact 6-digit Date (yymmdd) | `261024` |
| `{hms}` | Compact Time (24h) | `153045` |
| `{nano}` | Nanosecond Timestamp | `1792843200000000000` |
| `{tz}` | Time Zone ID | `Australia/Sydney` |
Expand All @@ -133,7 +137,7 @@ If your format string contains `{h12}` (12-hour clock) but lacks a `{mer}` token
*(If you explicitly want a 12-hour digit without an auto-appended meridiem, use the `:raw` modifier: `{h12:raw}`)*

> [!NOTE]
> **Why `{h12}`?** In most date libraries, `{hh}` means 12-hour and `{HH}` means 24-hour time. However, Tempo standardizes `{hh}` on the default 24-hour expectation (with `{h12}` serving as the specific 12-hour override). This keeps all token definitions, and their corresponding time getters, fully lowercase and semantic.
> **Why `{h12}` and `{h24}`?** In other date libraries, `{hh}` could mean 12-hour and `{HH}` means 24-hour time. This is confusing and error-prone. Tempo standardizes `{hh}` on the default 24-hour expectation, but provides explicit `{h12}` and `{h24}` tokens to completely eliminate ambiguity. This keeps all token definitions fully lowercase and semantic, without relying on uppercase variations like `{HH}`.

```typescript
t.format('{h12}:{mi}'); // "03:30pm" (auto-append standard meridiem)
Expand Down
2 changes: 1 addition & 1 deletion packages/tempo/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@magmacomputing/tempo",
"version": "3.2.1",
"version": "3.2.2",
"engines": {
"node": ">=20.0.0"
},
Expand Down
35 changes: 19 additions & 16 deletions packages/tempo/src/module/module.format.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import '#library/temporal.polyfill.js';
import { pad, toTitleCase } from '#library/string.library.js';
import { deepMerge } from '#library/object.library.js';
import { suffix } from '#library/number.library.js';
import { ifNumeric } from '#library/coercion.library.js';
import { isString, isObject, isZonedDateTime, isInstant, isPlainDate, isPlainDateTime, isUndefined, isDefined, isFunction } from '#library/assertion.library.js';
import { formatDayPeriod, getDTF, getPR } from '#library/international.library.js';
import { formatDayPeriod, getDTF, getPR, getISOWeekOfYear } from '#library/international.library.js';
import { delegator } from '#library/proxy.library.js';

import { isTempo, enums, Match, getRuntime, NumericPattern, BigIntPattern } from '#tempo/support';
Expand Down Expand Up @@ -48,7 +49,7 @@ export function format(obj?: any, fmt?: any, options?: any): any {

if (options) {
config = { ...baseConfig, ...options };
if (options.intl) config.intl = { ...baseConfig?.intl, ...options.intl };
if (options.intl) config.intl = deepMerge(baseConfig?.intl || {}, options.intl);

if (options.registry) {
config.registry = { ...baseConfig?.registry, ...options.registry };
Expand Down Expand Up @@ -122,9 +123,9 @@ export function format(obj?: any, fmt?: any, options?: any): any {
? (formats as Record<string, string>)[fmt as string]
: String(fmt);

// auto-meridiem: if {h12} or {HH} is present and {mer} is absent, append it after the last time component
if (/(?:\{h12|\{HH)/.test(template) && !template.toLowerCase().includes('{mer')) {
const hMatch = template.match(/\{(h12|HH)[^}]*\}/);
// auto-meridiem: if {h12} is present and {mer} is absent, append it after the last time component
if (template.includes('{h12') && !template.includes('{mer')) {
const hMatch = template.match(/\{h12[^}]*\}/);
let merMod = '';
let skipMeridiem = false;
if (hMatch) {
Expand All @@ -141,7 +142,7 @@ export function format(obj?: any, fmt?: any, options?: any): any {
const matches = [...template.matchAll(rgx)];
return matches.length ? matches[matches.length - 1].index! : -1;
}
const hIndex = Math.max(lastSearch(/\{h12[^}]*\}/g), lastSearch(/\{HH[^}]*\}/g));
const hIndex = lastSearch(/\{h12[^}]*\}/g);
const miIndex = lastSearch(/\{mi[^}]*\}/g);
const ssIndex = lastSearch(/\{ss[^}]*\}/g);
const subIndex = Math.max(
Expand All @@ -166,8 +167,13 @@ export function format(obj?: any, fmt?: any, options?: any): any {
switch (token) {
case 'yyyy': res = pad(zdt.year, 4); break;
case 'yy': res = pad(zdt.year % 100); break;
case 'yw': res = pad(zdt.yearOfWeek, 4); break;
case 'yyww': res = pad(zdt.yearOfWeek, 4) + pad(zdt.weekOfYear); break;
case 'yw': res = pad(getISOWeekOfYear(zdt).yearOfWeek, 4); break;
case 'ww': case 'wy': res = pad(getISOWeekOfYear(zdt).weekOfYear); break;
case 'yyww': case 'yywy': {
const { weekOfYear, yearOfWeek } = getISOWeekOfYear(zdt);
res = pad(yearOfWeek, 4) + pad(weekOfYear);
break;
}
case 'mm': res = pad(zdt.month); break;
case 'mon': res = enums.MONTHS.keyOf(zdt.month as any); break;
case 'mmm': res = enums.MONTH.keyOf(zdt.month as any); break;
Expand All @@ -176,15 +182,9 @@ export function format(obj?: any, fmt?: any, options?: any): any {
case 'dow': res = zdt.dayOfWeek.toString(); break;
case 'wkd': res = enums.WEEKDAYS.keyOf(zdt.dayOfWeek as any); break;
case 'www': res = enums.WEEKDAY.keyOf(zdt.dayOfWeek as any); break;
case 'ww': res = pad(zdt.weekOfYear); break;
case 'DAY': res = suffix(zdt.day); break;
case 'WW': res = suffix(zdt.weekOfYear); break;
case 'MM': res = suffix(zdt.month); break;
case 'hh': res = pad(zdt.hour); break;
case 'h12':
case 'HH': res = pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); break;
case 'h24': case 'hh': res = pad(zdt.hour); break;
case 'h12': res = pad(zdt.hour > 12 ? zdt.hour % 12 : zdt.hour || 12); break;
case 'mer': res = zdt.hour >= 12 ? 'pm' : 'am'; break;
case 'MER': res = zdt.hour >= 12 ? 'PM' : 'AM'; break;
case 'mi': res = pad(zdt.minute); break;
case 'ss': res = pad(zdt.second); break;
case 'ms': res = pad(zdt.millisecond, 3); break;
Expand All @@ -194,6 +194,9 @@ export function format(obj?: any, fmt?: any, options?: any): any {
case 'dmy': res = `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year, 4)}`; break;
case 'mdy': res = `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year, 4)}`; break;
case 'ymd': res = `${pad(zdt.year, 4)}${pad(zdt.month)}${pad(zdt.day)}`; break;
case 'dmy6': res = `${pad(zdt.day)}${pad(zdt.month)}${pad(zdt.year % 100)}`; break;
case 'mdy6': res = `${pad(zdt.month)}${pad(zdt.day)}${pad(zdt.year % 100)}`; break;
case 'ymd6': res = `${pad(zdt.year % 100)}${pad(zdt.month)}${pad(zdt.day)}`; break;
case 'hms': res = `${pad(zdt.hour)}${pad(zdt.minute)}${pad(zdt.second)}`; break;
case 'ts': res = ((config?.timeStamp ?? 'ms') === 'ss')
? Math.trunc(zdt.epochMilliseconds / 1000).toString()
Expand Down
13 changes: 11 additions & 2 deletions packages/tempo/src/support/support.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { LOG } from '#library/logger.class.js';

import { NUMBER, MODE, MONTH_DAY } from './support.enum.js';
import { Token } from './support.symbol.js';
import { IntlDefault } from './support.intl.js';
import type { Options, AliasContext } from '../tempo.type.js';
import type { Options, AliasContext, IntlOptions } from '../tempo.type.js';

/** characters allowed inside timezone/calendar brackets */
const bracket_content = /[^\]]+/;
Expand Down Expand Up @@ -191,6 +190,16 @@ export const Guard = [
'mondays', 'tuesdays', 'wednesdays', 'thursdays', 'fridays', 'saturdays', 'sundays'
] as const;

/** @internal baseline Intl settings */
export const IntlDefault: IntlOptions = {
relativeTimeFormat: {
style: 'narrow',
},
durationFormat: {
style: 'long',
}
}

/** @internal Tempo Default options */
export const Default = secure({
/** log to console */ debug: LOG.Info,
Expand Down
8 changes: 4 additions & 4 deletions packages/tempo/src/support/support.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const DEFAULTS = {
/** display with Time */ dayTime: '{dd}-{mmm}-{yyyy} {hh}:{mi}:{ss}',
/** useful for stamping logs */ logStamp: '{ymd}T{hms}.{ff}',
/** useful for sorting display-strings */ sortTime: '{yyyy}-{mm}-{dd} {hh}:{mi}:{ss}',
/** useful for sorting week order */ yearWeek: '{yw}{ww}',
/** useful for sorting week order */ yearWeek: '{yw}{wy}',
Comment thread
magmacomputing marked this conversation as resolved.
/** useful for sorting month order */ yearMonth: '{yyyy}{mm}',
/** useful for sorting date order */ yearMonthDay: '{ymd}',
/** just Date portion */ date: '{yyyy}-{mm}-{dd}',
Expand Down Expand Up @@ -182,7 +182,7 @@ export type FORMAT = typeof FORMAT;
export type Format = LooseUnion<KeyOf<typeof FORMAT> & string>

/** patterns that return a number */
export const NumericPattern = ['{yyyy}{ww}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yyww}', '{yw}{ww}', '{yw}', '{ymd}', '{ymd6}'] as const;
export const NumericPattern = ['{yyyy}{wy}', '{yyyy}{mm}', '{yyyy}{mm}{dd}', '{yywy}', '{yw}{wy}', '{yw}', '{ymd}', '{ymd6}', '{hms}', '{ff}', '{dmy}', '{dmy6}', '{mdy}', '{mdy6}'] as const;
export type NumericPattern = typeof NumericPattern[number]

/** patterns that return a bigint */
Expand Down Expand Up @@ -214,11 +214,11 @@ export const MONTH_DAY = proxify(STATE.MONTH_DAY, true, false);
export const LOCALE = proxify(STATE.LOCALE, true, true);

/** date-time element tokens */
const elementKeys = ['yy', 'mm', 'ww', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const;
const elementKeys = ['yy', 'mm', 'wy', 'dd', 'hh', 'mi', 'ss', 'ms', 'us', 'ns'] as const;
export const ELEMENT = enumify({
yy: 'year',
mm: 'month',
ww: 'week',
wy: 'week',
dd: 'day',
hh: 'hour',
mi: 'minute',
Expand Down
3 changes: 2 additions & 1 deletion packages/tempo/src/support/support.init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { asArray } from '#library/coercion.library.js';
import { getDateTimeFormat, getHemisphere, canonicalLocale } from '#library/international.library.js';
import { normalizeUtcOffset } from '#library/temporal.library.js';
import { markConfig } from '#library/symbol.library.js';
import { deepMerge } from '#library/object.library.js';
import { asType } from '#library/type.library.js';
import { isString, isObject, isUndefined, isDefined, isRegExp, isEmpty } from '#library/assertion.library.js';
import { ScopedSet } from '#library/scopedset.class.js';
Expand Down Expand Up @@ -289,7 +290,7 @@ export function extendState(state: t.Internal.State, options: t.Options): boolea

case 'intl':
if (!isObject(state.config.intl)) setProperty(state.config, 'intl', {});
state.config.intl = { ...state.config.intl, ...arg.value };
state.config.intl = deepMerge(state.config.intl, arg.value);
break;

case 'planner':
Expand Down
Loading
Loading