Repository navigation
Releases: DirazCoder/temporal-fmt
Release list
0.9.71
temporal-fmt v0.9.71
Added
numberingSystem/parseNumberingSystemaccept'auto'— resolves
whichever numeral system the call's own locale actually uses, via
Intl.NumberFormat(locale).resolvedOptions().numberingSystem, instead of
making the caller hardcode a system code per locale.{ numberingSystem: 'auto', locale: 'ar-EG' }gets Arabic-Indic digits,bn-BDgets Bengali,
en-USstays ASCII — same option, no per-locale branching in caller code.
Works the same on the parse side, and throughcreateConfig(): a config's
numberingSystem: 'auto'resolves per call against whichever locale that
call ends up using, so one shared config can drive different digits for
different callers instead of pinning a single system. Default's still
'latn', so if you're not passing the option, nothing about your existing
calls changes. When a locale's native system isn't one this library can
transliterate — Thai, Lao, Myanmar —'auto'falls back to'latn'rather
than throwing; only a malformed locale tag throws.
Fixed
MMMM/MMM/EEEE/EEE(and stand-aloneLLLL/cccc) rendered the
wrong month or weekday for dates before October 15, 1582. Turns out ICU's
gregorycalendar applies a Julian cutover at that date —Intl.Date- TimeFormatwas silently reinterpreting anything earlier under Julian
rules, even thoughTemporalitself is proleptic Gregorian throughout.
1500-07-05would come back labeled "June," on the wrong weekday, because
Julian ran about 10 days behind proleptic Gregorian by then. Numeric tokens
were never affected — they don't touchIntlat all, only the name lookups
did. Fixed by never handing Intl the historical date in the first place:
the month/weekday number now comes straight offTemporal's own proleptic
fields, then gets looked up against a safe modern reference date (the same
ones the locale vocab already builds from, so format output still parses
straight back). Non-Gregorian-calendar objects — Hebrew, Islamic — were
never touched by the bug and aren't touched by the fix either; ICU doesn't
apply this cutover to them, so that path was already correct.
Docs
- README's "Known limitations" section is gone. Every item in it had
already grown a fuller writeup somewhere else in the doc — this was just a
stale summary duplicating them. Two of those sections picked up a line
this changelog entry made newly relevant: the actual fix for ambiguous
glued tokens is zero-padding or a separator, not just thelenientflag;
andxxxis the one offset token that can carry a sub-minute historical
offset (Europe/Londonpre-1847) through verbatim, where the other five
throw.
0.9.70
temporal-fmt v0.9.70
Added
ctx.registerFormatToken(token)adds a custom token into the actual
shared tableformat()/parse()read from, everywhere in the process —
createFormatter()'s ownoptions.tokensonly ever reached the one
Formatterobject it returned, never the library's top-levelformat().
Last-write-wins by name, same rule asregisterLocale, including against
a built-in token name;Mod.priorityis the tiebreak. Custom handlers
still run host-side under the sandbox, same treatmentcreateFormatter's
tokens already got: source text crosses the boundary, gets revived with
new Function, and is checked against the original before the mod's load
is accepted.ctx.log(level, message, meta?)andctx.reportIssue(issue)give a
mod a way to actually surface something to the person running the CLI.
Sandboxed mods' ownconsole.loggoes to the worker's stderr, which
isn't the terminal's stderr, so without these two a mod had no way to
say "here's what I did" or "here's what I couldn't do" that anyone would
see. Both show up in the load report; both fall back to the matching
consolemethod when used outside a load report entirely."minApiLevel"inmod.jsonlets a.tfmoddeclare the lowest mod API
level it needs. A mod that needs more than the host provides now fails
cleanly at load time, beforeregister()runs, naming both numbers —
previously it would run right up to the first missingctxfunction and
fail with a raw "is not a function."
Changed
ctx.hasPermission(capability)is now typed as the literal union of
the four real capabilities instead of a barestring. A typo like
'fs:reed'is a compile-time error in a TypeScript-authored mod now,
not a capability that silently and permanently readfalse. No runtime
behavior change.
Fixed
registerFormatTokendidn't do anything.tokens.tshad the full
mod-registration and rebuild-listener mechanism in place, but neither
tokenize.ts's token-matching table norformat.ts's handler lookup
ever subscribed to it — both were built once from the static built-in
token list at module load and never touched again. A mod's registration
was accepted and then quietly ignored by everyformat()call. Both
tables now rebuild on registration, andformat.ts's tokenize cache is
cleared at the same time, since a format string already tokenized as
literal text before a token was registered would otherwise never
re-tokenize for the rest of the process.
0.9.61
temporal-fmt v0.9.61
Fixed
checkVersionRangeinscripts/semverRange.mjscompared host and range
versions by raw patch-number arithmetic, which misordered releases whose
patch numbers don't increase monotonically with release order — e.g.
0.9.41(shipped Sep 7) read as newer than0.9.5(shipped Sep 11)
because41 > 5numerically, when0.9.5is actually the later release.
A mod declaring^0.9.5could incorrectly load against a host on0.9.41,
or incorrectly reject a host on0.9.5itself. Comparisons are now
resolved againstscripts/versions.json, a table generated from this
changelog's actual release order via the newscripts/generateVersionTable.mjs,
not computed from the version string.scripts/versions.jsonwas missing from the package's publishedfiles
list. SincesemverRange.mjsrequires it at module load time, every
install of0.9.4through0.9.6threwMODULE_NOT_FOUNDthe first time
any mod-version check ran. Added it tofiles.
0.9.6
temporal-fmt v0.9.6
Security
- Mods no longer run in the host process at all. Every mod — loose
.mjs
and.tfmodalike — now loads inside its own subprocess, started with
Node's permission model (--permissionon Node 22.13+,
--experimental-permissionon anything older), and gets zero capabilities
by default. Previouslyregister()ran via a plainimport(), which meant
a mod could read/write anywhere on disk or spawn whatever it wanted with
the exact same access as the CLI process itself — there was no boundary
between "code that formats dates" and "code that does anything Node can
do." Filesystem reads/writes, child-process spawns, and worker threads are
all refused by Node itself now unless the user explicitly grants them. - The subprocess never sees the host's environment.
process.envinside a
mod is scrubbed down toPATH/TZ/LANGplus a couple of Windows
bootstrap variables — CI tokens, database URLs, anything else living in the
parent's env doesn't cross over. There's no permission that hands this
back, either: the permission model has no flag that gates environment
variables, so the only honest option was to just not pass them in. - A mod that hangs or leaks memory can no longer take the whole CLI command
down with it.register()gets killed after 10 seconds; a runtime
override call (format()/parse()routed through a mod'soverrideFormat
etc.) gets 5; a subprocess whose RSS crosses 512MB gets killed regardless
of what it's doing. That ceiling is watched as actual resident memory from
the parent process, not V8's heap accounting —ArrayBufferallocations
count, and setting--max-old-space-sizeanywhere in the process tree
can't quietly raise it. Any of these failing only takes down that one mod;
the rest of the load pass and the command you ran keep going.
Added
.tfmodmods can declare a"permissions"array inmod.json—
fs:read,fs:write,child-process,worker, each markable required or
optional. First load prompts the user once pername@versionand caches
the answer in.temporal-fmt-permissions.json, next tomods/. A denied
required permission fails the mod before any of its code runs, with the
exact grant command in the error; a denied optional one just runs the mod
with less and shows up in the report asdowngradedinstead ofloaded.
Loose.mjsmods have no manifest to declare any of this in, so they
always run with nothing granted — that's the trade for not needing one.ctx.hasPermission(capability), so a mod can check what it actually got
and skip the parts ofregister()that need something denied, instead of
crashing partway through.scripts/managePermissions.mjs—list/grant/deny/reset
against the same cache file the load-time prompts write to, for changing an
answer without re-triggering a load.- The load report now spells out sandboxing per mod: which capabilities
were granted vs. denied, and — for any mod holding a runtime override — the
timeout and memory ceiling it's running under. MODS.md, a standalone guide with everything that used to live in the
README's "Mods" section plus all of the above; the README section is now a
pointer to it.
Changed
overrideFormat/overrideParse(and the otheroverride*points): a
mod's impl is still a closure, but that closure now lives inside the mod's
own subprocess, not the host. Everyformat()/parse()call that hits an
overridden path forwards to it over a synchronous channel and blocks for
the reply — a couple of milliseconds instead of nanoseconds, plus a bit
more on Windows, where the host has no file descriptor for its end of a
named pipe and the call rides a pair of scratch files instead. The loader
tracks, per format string, whether a mod's impl is just forwarding to the
built-in — a format string the mod never actually touches stops paying the
round trip after the first call.formatRange()batches its two endpoint calls into a single subprocess
round trip when the active override lives in a mod, instead of paying for
each endpoint separately.createFormatter()custom token handlers are the one thing that still
runs host-side, since a hotformat()path can't afford a round trip per
token lookup. The handler's source text ships out of the subprocess at
register()time and gets rebuilt there withnew Function, which only
works if the handler doesn't close over anything outside its own body — the
loader checks this by reviving the handler from source and diffing its
output against the original's, and refuses the mod if they disagree.- Network access is still unrestricted — Node's permission model has no
netcapability on any currently supported version, so there was never a
flag to gate it with. That was already true before this release; it's just
written down explicitly inMODS.mdnow instead of left for someone to
discover the hard way.
0.9.5
temporal-fmt v0.9.5
Security
.tfmodmod archives:mod.json's"main"field and the mod name are now
path-validated before either one touches the filesystem. A malicious archive
could previously declare"main": "../../../some/file.mjs"and have the
loaderimport()code from OUTSIDE its extraction directory — breaking the
"archive contents == executed code" containment the mod system documents
(the mod name had the same traversal shape via config reads). Absolute
paths,..segments, and symlink escapes are all rejected before
stat/import/read now; extraction itself was never the gap (the system tar
binary already refuses..members on this platform) — these checks close
off the manifest-controlled paths tar can't see.formatDistance/formatDistanceToNowreject years outside
[-271821, 275760] (Temporal's representable range) instead of walking a
year-per-iteration day-count loop toward them. A hostile duck-typed field
bag ({year: 2e8}from parsed user data, say) turned that loop into a cheap
CPU-denial primitive — about 380ms/call at 2e8, scaling roughly linearly
past that. The day-count itself is now O(1) via the Hinnant closed form, so
this isn't just a range check bolted on top of the slow path.- Out-of-range month/day in
formatDistancefield bags now throw the typed
InvalidDateError. Month 0 used to hit a V8-internalIntlerror with no
useful message, and Feb 31 silently compared as "now."
Fixed
interval:contains()had its two half-open bound labels swapped —
'half-open-end'excluded its start and included its end, which is exactly
what'half-open-start'is supposed to do, and vice versa.intersects/
intersection/union/differenceall derive endpoint ownership from
that same logic, so they inherited the bug: two point-disjoint open
intervals could come back "overlapping" with a non-null empty intersection.
splitIntervalnow produces a true partition instead of dropping the
original end point and double-covering interior boundaries, and
mergeIntervalsno longer aliases caller input objects.round/floor/ceil/truncate: floor and ceil were literally swapped
for every pre-1970 date.floor(1969-12-31T12:00, 'day')returned Jan 1
1970 — the opposite of what floor means — and'nearest'broke ties
asymmetrically across the epoch.roundDurationfloor/ceil on negative
durations now matches Temporal semantics, and a fractional
roundingIncrementthrows the typedInvalidDurationErrorinstead of a raw
V8 BigIntRangeError.- Proleptic-Gregorian day counts: the
Math.floor((y2 >= 0 ? y2 : y2-399) / 400)era form — Hinnant's truncating-division idiom, ported straight into
JS's flooring division without adjusting for the difference — double-
corrected for negative years, shifting every pre-year-0 date by one day
acrossdifference,splitInterval, and anything in thecomparefamily.
Replaced with the actual closed form and verified against Temporal over
34,320 round-trip cases. add()-family weekday recomputation usedDate.UTC(year, month-1, day),
which remaps years 0-99 to 1900-1999 per the ECMAScript spec. First-century
dates — including genuineTemporal.PlainDateinput — got the wrong
dayOfWeekout of that, which meantisBusinessDaywas counting Sundays as
business days. All four call sites now use an O(1) proleptic formula
instead of routing throughDate.- Month arithmetic across year 0 double-borrowed the year:
add(PlainDate('0001-01-15'), -13, 'months')returned year -2 / month 12
instead of Temporal's -0001-12-15. - Recurrence, several bugs in the same area:
FREQ=WEEKLY;BYDAY=…never
matched when the start date wasn't itself on a BYDAY weekday, since whole-
week stepping is supposed to preserve the weekday and didn't; RRULEUNTIL
was stored as the raw string, so the firstnext()call threw; BYDAY
ordinals (2MO,-1FR) were silently dropped; negativeBYMONTHDAYnever
matched anything; monthly rules drifted day-of-month after the first
short-month clamp (Jan 31 → 28, forever — RFC 5545 says skip the month
instead);skip()'s skip phase andbetween()both had unbounded
iteration; a ruleintervalofundefinedpoisoned candidates with NaN
even though 1 is the documented default; programmatic rules now validate
their BY* lists instead of accepting garbage silently. isDSTassumed northern hemisphere and was inverted year-round for
Australia/NZ/South America/southern Africa — standard offset is now
min(Jan, Jul)instead of always-January.getNextTransition/
getPreviousTransitionnow bisect to the actual transition minute; they
used to return the day reconstructed at the input's wall-clock time, which
was the right day up to 10 hours late.getTransitionsvalidates itsend
argument now too.- Codemod tables: date-fns
S/SS(fractional seconds) andyexisted in
temporal-fmt already but were missing from the mapping table, so they
silently translated to quoted literal text instead of the real token, or
threw.wwlocale-vs-ISO week mismatches now refuse with an explanatory
note instead of silently printing two different numbers depending on which
week system got picked. A large batch of real-but-unmapped tokens now fail
loudly instead of quietly literalizing. analyzeFormattoken positions now track actual source indices — quoted
spans and''escapes were under-counted before, which mis-positioned
every token that came after any quote in the string.previewFormat's
default sample is now a realPlainDateTime, so locale-aware tokens
actually render in the preview.ww/RRRR/D-family tokens validate all
the fields they read instead of producing NaN silently, and the token
metadata no longer claimsInstantsupports time tokens or
PlainMonthDaysupports day-of-year.compileFormat().piecesis now served frozen. A caller mutating the
(shared, cached) array was poisoning every subsequentformat()call for
that string, process-wide.DEFAULT_CONFIGis frozen too, for the same
reason — one import site mutating it could change every later
createConfig()'s defaults.registerRelativeGrammarnow validates that
matchers are actually functions and isolates ones that throw;
parseRelativerejects contradictory "in N units ago" input instead of
silently resolving it as future; the CLI rejects prototype-chain subcommand
lookups (temporal-fmt toString …used to crash with a rawTypeError);
formatISODurationemits valid ISO for negative durations (-PT30S
instead of the unparseablePT-30S) andparseISODurationaccepts that
leading-minus form on the way back in; the holiday calendar's per-year
cache is bounded now (1000 entries, FIFO);format(null, …)throws the
typed error instead of a rawTypeError.interval: the end-inclusivity ownership checks insideintersection()
andunion()compared in the wrong direction, which made the "other
interval strictly extends further" branch unreachable and computed end
bounds from the wrong interval entirely.union((1,5], [3,10))reported an
inclusive end even though 10 is in neither interval; the mirror bug made
intersect([1,10), [3,5])exclude 5 even though 5 is in both. Found this
during a line-by-line coverage audit — an unreachable branch on a live code
path is usually a bug report wearing a disguise.timezone:isDST()'s string-offset fallback (used when the installed
Temporal implementation doesn't exposeoffsetNanoseconds) was comparing
seconds against nanoseconds, so the comparison could never come out equal.
Every DST-observing zone read as "in DST" year-round on implementations
that hit this fallback. Both arms are normalized to seconds now.recurrence: the next-match safety cap wasn't counting iterations that
hit acontinue— empty period, candidate at or before the start — so a
rule whose every period is empty (FREQ=MONTHLY;BYDAY=-6MO, which no month
can satisfy) spun forever instead of terminating with "no more
occurrences." The cap counts every iteration now.recurrence:parseRRule()'s BYDAY pattern only accepted single-digit
ordinals, but RFC 5545 ordinals run -53..53 — a valid rule like
BYDAY=53MOthrew "invalid weekday" and never even reached the range check
that already existed for it. Two-digit ordinals parse now.recurrence:formatRRule()emittedINTERVAL=undefinedfor
programmatic rules that omitinterval(a documented default), which
parseRRule()itself then rejects on the way back in — a broken round
trip. Omitted interval is now omitted from the output, matching what
parseRRule()expects.recurrence: a plain field-bag start withoutdayOfWeekmade every
BYDAY-dependent rule silently yield nothing — Temporal starts worked fine
because theirdayOfWeekgetter seeds the chain automatically. The
iterator now derivesdayOfWeekfor bag starts too, so both start shapes
behave the same.
Tests
npm run test:coverageis back to 100% lines/branches/functions/
statements — the audit above had knocked it down to 98.94%. ~60 new tests
intest/cov-audit.test.jscover the paths the audit surfaced, and the
handful of genuinely unreachable defensive arms (each documented at its own
site) carry/* c8 ignore ... @preserve */hints so they survive the
esbuild bundle, matching the repo's existing convention.
0.8.985
temporal-fmt v0.8.985
Security
formatDistance/formatDistanceToNowreject years outside
[-271821, 275760] (Temporal's representable range) instead of walking a
year-per-iteration day-count loop toward them. A hostile duck-typed field
bag ({year: 2e8}from app-parsed user data, say) turned that loop into a
cheap CPU-denial primitive — around 380ms/call at year 2e8, scaling roughly
linearly past that. Real Temporal values always clear the check without
issue.format(…, 'ww'/'RRRR')with a duck-typed field bag whoseyearfalls
outside that same range now throws instead of walking the ISO-week Jan-1
weekday computation year by year — same CPU-denial shape, roughly
360ms/call at year 2e8. This is a security fix only: the 0.9.x line's
broader behavioral changes (O(1) closed forms, wider field validation)
aren't backported here on purpose — this LTS line only takes fixes,
not behavior changes.
0.9.41
temporal-fmt v0.9.41
License switched from MIT to Apache-2.0 — package.json, the
package-lock.json cache of it, root LICENSE, and the README's
license section are all updated, so there's no file left in the
repo still claiming MIT. Every file under src/ also picked up an
Apache-2.0 header (copyright line, grant, "AS IS" disclaimer),
same one-header-per-file convention as before.
Changed
- License: MIT → Apache-2.0, across package.json, package-lock.json,
LICENSE, and the README. - Apache-2.0 headers added to every file under src/.
0.8.984
temporal-fmt v0.8.984
Same relicensing as 0.9.41, backported to the 0.8.x line — MIT to
Apache-2.0 across package.json, package-lock.json, the root LICENSE
file, and the README, plus Apache-2.0 headers on every file under
src/. If you're pinned to 0.8.x, this is a license-only backport;
no code or behavior changed.
Changed
- License: MIT → Apache-2.0, across package.json, package-lock.json,
LICENSE, and the README. - Apache-2.0 headers added to every file under src/.
0.9.4
temporal-fmt v0.9.4
A mods/ folder now exists — drop a .mjs file in it and the CLI
picks it up on startup and registers it against the library, no
publishing or build step required. Modeled on the Minecraft mods-
folder convention: the host looks for files and runs them, rather
than depending on a package.
Added
- A mod default-exports
{ name, register(ctx, config) }, where
ctxis the same registration API (registerLocale,
registerLocaleVocab,registerRelativeGrammar,createFormatter,
createHolidayCalendar) already documented under Locales, Business
calendars, and Extending with custom tokens. .tfmodpackaging for mods bigger than one file: a gzipped tar
containingmod.json(name/version/main/requires/priority/
temporalFmtVersion/config) plusmain.mjsand an optionaldata/
directory. The manifest is readable without executing any code,
which is what makes dependency resolution happen beforemain.mjs
is ever imported. Extraction shells out to the systemtarbinary
rather than adding a parsing dependency.requires/priorityfields for controlling load order across
mods, resolved as a real dependency graph (Kahn's algorithm) rather
than just sorting by filename. Missing dependencies and circular
dependencies each fail only the mods involved; everything else
still loads.- Conflict reporting: if two mods register the same locale tag,
relative-time grammar, or custom token name, the load report says
which mod won (last-loaded wins, same as callingregisterLocale
twice already did — this just makes it visible). ctx.overrideFormat/ctx.overrideParse, plus the same
overrideXxx()shape for 79 more functions that have no internal
call sites in this library — the full list is in the README under
"Which functions are overridable." Unlike the additive registration
functions, only one mod may hold a given override point; a second
attempt fails immediately and names the mod that already owns it.temporalFmtVersioninmod.json(exact version or^caret
range, same semantics as npm) — checked beforemain.mjsis
imported, so a mod built against a newer override surface fails
cleanly instead of throwing from insideregister().- Per-mod user settings: a
configarray inmod.json(string/
number/boolean/enum, with optional min/max/choices) resolved
against an optionalconfig/<mod-name>.jsonfile kept as a sibling
ofmods/, not inside it, so re-downloading a.tfmodnever
touches a user's settings. Unknown keys and out-of-range values are
reported and fall back to the schema default rather than failing
the whole mod. loadMods()andformatModLoadReport()inscripts/loadMods.mjs
(CLI-only, not published — needsfs/path) for anyone embedding
temporal-fmtoutside the CLI who wants the same loader; the
published, Node-agnostic pieces it depends on (buildModContext(),
isMod()) are exported from the library itself.
Changed
src/index.ts's exports forformat,parse, and 79 other
previously-direct re-exports now route through a small override
registry (src/runtime.ts) instead of re-exporting straight from
their source modules. This is what makes a mod's override reach
every internal caller —formatRange()'s own use offormat(),
for instance — not just whoever importsformatfrom the package
root.mergeWithConfig()'s return type no longer asserts the merged
object back into the genericTit was called with; it now returns
its own actual shape (Tplus the five config fields, each
optional). The oldas T & {...}cast wasn't a guarantee a spread
could honestly make for an arbitrary caller-suppliedT, andtsc
caught it once this generic got exercised through a real call site
(the override wrapper inmodApi.ts).
0.9.32
temporal-fmt v0.9.32
README quickstart was steering people into the expensive import path.
Every code sample under "Providing Temporal", "Formatting", and
"Parsing" imported format/parse from the bare temporal-fmt
entry point — which works, but the main entry re-exports everything
in the package, so a bundler pulls the whole graph in regardless of
which function you actually call. Measured with esbuild: ~27KB for
format alone via the subpath, versus ~68KB for the same single
function through the bare import. The subpath imports weren't new —
they'd existed for a while — they just weren't what the docs led
with.
Docs
- Formatting, Parsing, and Providing
Temporalcode samples now
import fromtemporal-fmt/formatandtemporal-fmt/parseinstead
of the baretemporal-fmtentry. - Added a "Get started" section right after Install: a four-line
working example (oneformat()call, oneparse()call, both via
subpaths) before any discussion of package size or scope.
Previously the fastest path to running code was scattered across
three sections (Install → ProvidingTemporal→ Formatting); now
it's one. - Added the measured 27KB/68KB numbers directly to the Subpath
imports section, so the tree-shaking claim isn't just asserted —
sideEffects: falseis set inpackage.json, and this documents
what it actually gets you. - Added two bundlephobia badges (
formatandparsesubpaths) next
to the existing coverage badge, so subpath size is visible without
digging into the docs. - Rewrote the top-of-file description to match what the library
actually does now (format+parse, real validation, locale
support) instead of the original one-liner, which only mentioned
formatting and predated the parse API entirely.
Changed
package.json'sdescriptionfield was still the pre-parse
wording ("Format Temporal.PlainDate/PlainDateTime/PlainTime/ ZonedDateTime objects using date-fns-style token strings.") —
stale since parsing shipped. Updated to match the README's
description.
NOTE: Also unrelated to this changelog — the 0.9.x lineup is probably going to last a lot longer than usual. You might expect 0.9.x to wrap up in a week like it used to, but I've slowed things down so I'm not telling people to upgrade over and over, rapidly. I'll release at a sensible pace instead. 0.9.x might run 2-3 weeks longer, and 0.8-lts will stay supported for that much longer too. When 0.10.x lands, 0.8-lts is still supported per the new policy in VERSIONS.md. Since 0.9.x is lasting much longer, 0.10.x will probably last longer too — maybe 3-4 weeks — since this library is mostly complete now and it's more about maintenance and fixes. So my rough estimate: 0.8-lts should keep getting security updates for another 1-2 months. In 0.9.4 i am expecting a big feature addition its one but its worth it before i can call it fully complete.