Skip to content

v1.4.0

Choose a tag to compare

@arashm arashm released this 28 Jul 16:41
64fea40

format() is rewritten as a single-pass tokenizer, which adds square bracket escaping for literal text. Published to npm as jalali-date@1.4.0.

Escaping literal text

Identifiers are matched wherever they appear, including inside words, so literal text containing a Y, M, D or d used to come back mangled. Wrap it in square brackets:

date.format('[Day] D')        //=> Day 6
date.format('Day D')          //=> 6ay 6     (the D in "Day" is an identifier)
date.format('YYYY [در] MMMM') //=> 1393 در امرداد

Write [[] for a literal [. A bracketed run cannot itself contain ].

format() is single-pass

It used to be three sequential find-and-replace passes over the whole string — one each for year, month and day, each recursing over its own output. A value substituted by an earlier pass was still visible to a later one, so output could be re-scanned and substituted a second time. The tokenizer walks the format string once and never re-scans what it emits.

This does not on its own fix format('Monday'): the M and d in it are genuine identifiers, and escaping is the answer there. What single-pass guarantees is that a substituted value can never be mistaken for an identifier.

A run of an unsupported length is still left alone rather than partly consumed, so YYYYY and DDD come back as-is.

Behaviour changes

Two edge cases differ, which is why this is a minor rather than a patch release:

  • format('Dd') was returned unchanged and is now substituted. The old [dD]+ matched the mixed-case run as a single unit and bailed out.
  • An unterminated [ now opens literal mode that runs to the end of the format string, rather than being treated as ordinary text. format('MM [YYYY') was 08 [1396 and is now 08 YYYY.

Security

The first cut of the tokenizer required the closing bracket, which CodeQL flagged as js/polynomial-redos — correctly. Every [ scanned to the end of the string looking for a ], failed, gave the whole run back, and the global scan then advanced a single character and did it again. Timings confirmed the quadratic growth: 4,000 characters took 7.7 ms, 8,000 took 34.1 ms, 16,000 took 124.1 ms, 32,000 took 500.8 ms. At 200,000 it was roughly 20 seconds. format() takes a caller-supplied string, so an application passing a user-controlled format could be hung by it.

Making the closing bracket optional removes the backtracking entirely — the greedy run never has to give anything back, so each character is consumed once. The same 200,000-character input now takes 0.2 ms. There is a regression test with a time bound.

Development

Jest is replaced by Vitest (#67). 47 tests pass, lint is clean, and npm audit reports 0 vulnerabilities. The packed tarball was installed into a scratch project and exercised through both require and import before tagging.

lib/jdate.min.js is 4,771 bytes, down from 5,047.

Full changelog: 1.3.0...1.4.0