temporal-fmt 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 NovaByte Official
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NovaByte Official
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs CHANGED
@@ -30,23 +30,30 @@ function pad(n, len) {
30
30
  }
31
31
  var DEFAULT_LOCALE = "en-US";
32
32
  var formatterCache = /* @__PURE__ */ new Map();
33
+ var MAX_CACHE_SIZE = 500;
33
34
  function getFormatter(locale, options) {
34
35
  const key = locale + JSON.stringify(options);
35
36
  let formatter = formatterCache.get(key);
36
- if (!formatter) {
37
- formatter = new Intl.DateTimeFormat(locale, options);
38
- formatterCache.set(key, formatter);
37
+ if (formatter) {
38
+ return formatter;
39
39
  }
40
+ if (formatterCache.size >= MAX_CACHE_SIZE) {
41
+ const oldestKey = formatterCache.keys().next().value;
42
+ if (oldestKey !== void 0) formatterCache.delete(oldestKey);
43
+ }
44
+ formatter = new Intl.DateTimeFormat(locale, options);
45
+ formatterCache.set(key, formatter);
40
46
  return formatter;
41
47
  }
42
48
  function intlPart(temporal, locale, options, partType) {
43
- const isZoned = typeof temporal?.toInstant === "function" && typeof temporal?.timeZoneId === "string";
49
+ const { toInstant, timeZoneId } = temporal;
50
+ const isZoned = typeof toInstant === "function" && typeof timeZoneId === "string";
44
51
  const intlSafeTemporal = isZoned ? temporal.toInstant() : temporal;
45
52
  const calendar = temporal?.calendarId;
46
53
  const formatterOptions = {
47
54
  ...options,
48
55
  ...calendar && calendar !== "iso8601" ? { calendar } : {},
49
- ...isZoned ? { timeZone: temporal.timeZoneId } : {}
56
+ ...isZoned ? { timeZone: timeZoneId } : {}
50
57
  };
51
58
  const formatter = getFormatter(locale, formatterOptions);
52
59
  const parts = formatter.formatToParts(intlSafeTemporal);
@@ -60,7 +67,17 @@ function intlPart(temporal, locale, options, partType) {
60
67
  }
61
68
  var TOKENS = [
62
69
  ["yyyy", (t) => pad(t.year, 4), "year"],
63
- ["yy", (t) => pad(t.year % 100, 2), "year"],
70
+ // Negative years break the fixed 2-digit width (-45 % 100 === -45), and
71
+ // truncating with Math.abs() would make 45 CE and 45 BCE render the same
72
+ // string. Throw instead — use yyyy if you need the sign to survive.
73
+ ["yy", (t) => {
74
+ if (t.year < 0) {
75
+ throw new Error(
76
+ `temporal-fmt: token "yy" doesn't support negative years (got ${t.year}), since truncating to 2 digits would make it indistinguishable from a positive year. Use "yyyy" instead.`
77
+ );
78
+ }
79
+ return pad(t.year % 100, 2);
80
+ }, "year"],
64
81
  ["MMMM", (t, locale) => intlPart(t, locale, { month: "long" }, "month"), "month"],
65
82
  ["MMM", (t, locale) => intlPart(t, locale, { month: "short" }, "month"), "month"],
66
83
  ["MM", (t) => pad(t.month, 2), "month"],
@@ -95,7 +112,7 @@ function tokenize(format2) {
95
112
  const ch = format2[i];
96
113
  if (ch === "'") {
97
114
  if (format2[i + 1] === "'") {
98
- pieces.push({ kind: "literal", value: "'" });
115
+ appendLiteral(pieces, "'");
99
116
  i += 2;
100
117
  continue;
101
118
  }
@@ -119,7 +136,7 @@ function tokenize(format2) {
119
136
  if (!closed) {
120
137
  throw new Error(`temporal-fmt: unterminated quote in format string "${format2}"`);
121
138
  }
122
- pieces.push({ kind: "literal", value: literal });
139
+ appendLiteral(pieces, literal);
123
140
  i = j;
124
141
  continue;
125
142
  }
@@ -129,15 +146,29 @@ function tokenize(format2) {
129
146
  i += match.length;
130
147
  continue;
131
148
  }
132
- pieces.push({ kind: "literal", value: ch });
149
+ appendLiteral(pieces, ch);
133
150
  i += 1;
134
151
  }
135
152
  return pieces;
136
153
  }
154
+ function appendLiteral(pieces, value) {
155
+ const last = pieces[pieces.length - 1];
156
+ if (last && last.kind === "literal") {
157
+ last.value += value;
158
+ } else {
159
+ pieces.push({ kind: "literal", value });
160
+ }
161
+ }
137
162
 
138
163
  // src/format.ts
139
164
  var HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));
165
+ var MAX_FORMAT_LENGTH = 1e3;
140
166
  function format(temporal, formatStr, options = {}) {
167
+ if (formatStr.length > MAX_FORMAT_LENGTH) {
168
+ throw new Error(
169
+ `temporal-fmt: format string exceeds maximum length of ${MAX_FORMAT_LENGTH} characters (got ${formatStr.length}).`
170
+ );
171
+ }
141
172
  const locale = options.locale ?? DEFAULT_LOCALE;
142
173
  const pieces = tokenize(formatStr);
143
174
  let result = "";
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/tokens.ts","../src/tokenize.ts","../src/format.ts"],"sourcesContent":["export { format } from './format.js';\nexport type { TemporalLike, FormatOptions } from './tokens.js';","// Pad a number with leading zeros to `len` digits.\nexport function pad(n: number, len: number): string {\n return String(n).padStart(len, '0');\n}\n\n// Minimal duck-typed shape covering every field we might read off a Temporal\n// object. Not every field exists on every type (PlainDate has no .hour, for\n// example) — callers check for undefined before formatting a token.\n//\n// calendarId / toInstant are optional because a plain object satisfying\n// this interface in a test won't have them, but real Temporal instances\n// always do — intlPart() below relies on calendarId to keep Intl from\n// rejecting non-Gregorian objects, and on toInstant to detect ZonedDateTime.\nexport interface TemporalLike {\n year?: number;\n month?: number;\n day?: number;\n hour?: number;\n minute?: number;\n second?: number;\n millisecond?: number;\n timeZoneId?: string;\n dayOfWeek?: number; // 1 (Mon) - 7 (Sun), per Temporal spec\n calendarId?: string;\n toInstant?: () => unknown;\n}\n\nexport interface FormatOptions {\n /** BCP 47 locale tag, e.g. 'en-US', 'fr-FR', 'ar-EG'. Defaults to 'en-US'. */\n locale?: string;\n}\n\nexport const DEFAULT_LOCALE = 'en-US';\n\n// Small cache so repeated format() calls with the same (locale, options)\n// pair don't construct a fresh Intl.DateTimeFormat every time — these are\n// somewhat expensive to instantiate and format() may run in a loop (e.g.\n// rendering a table of dates).\nconst formatterCache = new Map<string, Intl.DateTimeFormat>();\n\nfunction getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = locale + JSON.stringify(options);\n let formatter = formatterCache.get(key);\n if (!formatter) {\n formatter = new Intl.DateTimeFormat(locale, options);\n formatterCache.set(key, formatter);\n }\n return formatter;\n}\n\n// Reads a single named part (e.g. 'month', 'weekday', 'dayPeriod') out of\n// Intl's formatToParts() output. We ask Intl for exactly one field at a\n// time rather than building a full localized string and slicing it apart —\n// slicing is what breaks under RTL scripts and locales with different\n// field ordering (e.g. year-month-day vs day-month-year).\n//\n// `temporal: any` — deliberately untyped. Real Temporal.PlainDate /\n// PlainDateTime / ZonedDateTime instances (not the TemporalLike interface)\n// are what get passed through to Intl at runtime.\nfunction intlPart(\n temporal: any,\n locale: string,\n options: Intl.DateTimeFormatOptions,\n partType: Intl.DateTimeFormatPartTypes\n): string {\n // Intl.DateTimeFormat.formatToParts() always throws on Temporal.ZonedDateTime\n // specifically — deliberate per spec (see\n // Temporal.ZonedDateTime.prototype.toLocaleString docs), not a bug here.\n // Fix: convert to an Instant and pass the zone through the formatter's own\n // `timeZone` option. Converting to PlainDateTime instead would silently\n // drop the timezone info, which breaks combining e.g. 'MMMM' with 'zzz'.\n const isZoned = typeof temporal?.toInstant === 'function' && typeof temporal?.timeZoneId === 'string';\n const intlSafeTemporal = isZoned ? temporal.toInstant() : temporal;\n\n // Intl.DateTimeFormat hard-errors (\"Mismatching Calendars\") if the\n // formatter's resolved calendar doesn't match the Temporal object's own\n // calendar — an 'en-US' formatter defaults to gregory, so feeding it a\n // hebrew- or islamic-calendar PlainDate throws unless the formatter is\n // told which calendar to use. We read the calendar off the object itself\n // rather than guessing from the locale, so whatever calendar the caller's\n // Temporal object carries just works — not only Gregorian.\n //\n // Deliberately NOT passed when calendarId is 'iso8601' (the default for\n // plain Temporal objects nobody explicitly gave a calendar to): passing\n // `calendar: 'iso8601'` explicitly to Intl, combined with a single-field\n // options object like `{ month: 'long' }`, makes formatToParts() return\n // an empty parts array instead of the month — a real quirk in how Intl\n // resolves 'iso8601' with partial options, verified against\n // temporal-polyfill/full. Omitting the option entirely sidesteps it and\n // Intl's own default already matches what an iso8601 object needs.\n const calendar = temporal?.calendarId;\n const formatterOptions: Intl.DateTimeFormatOptions = {\n ...options,\n ...(calendar && calendar !== 'iso8601' ? { calendar } : {}),\n ...(isZoned ? { timeZone: temporal.timeZoneId } : {}),\n };\n\n const formatter = getFormatter(locale, formatterOptions);\n const parts = formatter.formatToParts(intlSafeTemporal);\n const part = parts.find((p) => p.type === partType);\n if (!part) {\n throw new Error(\n `temporal-fmt: locale \"${locale}\" produced no \"${partType}\" part for this token. ` +\n `This usually means the Temporal object is missing the field the token needs.`\n );\n }\n return part.value;\n}\n\n// Each token knows how to render itself from a TemporalLike + locale, and\n// which field it depends on (used to validate the input actually has that\n// field before we try to format it).\ntype TokenHandler = (t: TemporalLike, locale: string) => string;\n\n// Longest tokens first — the tokenizer is greedy, so \"yyyy\" must be tried\n// before \"yy\" or it'll never match.\n//\n// Numeric tokens (yyyy, MM, dd, HH, mm, ss, SSS) deliberately always render\n// in Western (0-9) digits regardless of locale, even though Intl could give\n// us locale-native digits (Arabic-Indic, Devanagari, etc. via\n// numberingSystem). Conscious choice, not an oversight: mixing locale\n// numeral systems into our pad()-based width logic is a real rabbit hole\n// (padding \"٣\" to 2 digits isn't the same operation as padding \"3\"), and\n// most consumers parsing these strings back out (logs, APIs, filenames)\n// want predictable ASCII digits. Documented as a known limitation in the\n// README rather than silently guessed at here.\nexport const TOKENS: Array<[string, TokenHandler, keyof TemporalLike]> = [\n ['yyyy', (t) => pad(t.year!, 4), 'year'],\n ['yy', (t) => pad(t.year! % 100, 2), 'year'],\n ['MMMM', (t, locale) => intlPart(t, locale, { month: 'long' }, 'month'), 'month'],\n ['MMM', (t, locale) => intlPart(t, locale, { month: 'short' }, 'month'), 'month'],\n ['MM', (t) => pad(t.month!, 2), 'month'],\n ['M', (t) => String(t.month!), 'month'],\n ['dd', (t) => pad(t.day!, 2), 'day'],\n ['d', (t) => String(t.day!), 'day'],\n ['EEEE', (t, locale) => intlPart(t, locale, { weekday: 'long' }, 'weekday'), 'dayOfWeek'],\n ['EEE', (t, locale) => intlPart(t, locale, { weekday: 'short' }, 'weekday'), 'dayOfWeek'],\n ['HH', (t) => pad(t.hour!, 2), 'hour'],\n ['H', (t) => String(t.hour!), 'hour'],\n ['hh', (t) => pad(t.hour! % 12 || 12, 2), 'hour'],\n ['h', (t) => String(t.hour! % 12 || 12), 'hour'],\n ['mm', (t) => pad(t.minute!, 2), 'minute'],\n ['m', (t) => String(t.minute!), 'minute'],\n ['ss', (t) => pad(t.second!, 2), 'second'],\n ['s', (t) => String(t.second!), 'second'],\n ['SSS', (t) => pad(t.millisecond!, 3), 'millisecond'],\n // dayPeriod ('AM'/'PM' in en-US, 'م'/'ص' in ar-EG, etc.) is locale-specific\n // — some locales render it differently or don't split 12-hour at all. We\n // still require .hour on the input either way, since that's what Intl\n // needs to compute which period it is.\n ['a', (t, locale) => intlPart(t, locale, { hour: 'numeric', hour12: true }, 'dayPeriod'), 'hour'],\n ['zzz', (t) => t.timeZoneId!, 'timeZoneId'],\n];","import { TOKENS } from './tokens.js';\n\nexport type Piece =\n | { kind: 'token'; value: string }\n | { kind: 'literal'; value: string };\n\n// Sort once, longest-first, so the greedy scanner below never matches \"M\"\n// when \"MMMM\" was actually there.\nconst SORTED_TOKEN_STRINGS = TOKENS.map(([tok]) => tok).sort((a, b) => b.length - a.length);\n\n/**\n * Splits a format string like `\"yyyy-MM-dd 'at' HH:mm\"` into a sequence of\n * token and literal pieces. Text inside single quotes is always literal —\n * that's how you escape a token that would otherwise be parsed (e.g. a\n * literal \"d\" in \"3rd\" — write 'rd' in quotes so it isn't read as the day token).\n * A doubled quote ('') anywhere means a literal single quote character —\n * this works both inside an open quoted span (e.g. 'it''s' -> it's) and\n * as a standalone escape outside one (e.g. yyyy'' -> \"2026'\").\n */\nexport function tokenize(format: string): Piece[] {\n const pieces: Piece[] = [];\n let i = 0;\n\n while (i < format.length) {\n const ch = format[i];\n\n if (ch === \"'\") {\n // Doubled quote is always a literal ' — check this before treating\n // the quote as an open-delimiter, or \"''best''\" gets misread as\n // \"open quote, then bare text, then open quote\" instead of two\n // separate escaped-apostrophe literals around plain text.\n if (format[i + 1] === \"'\") {\n pieces.push({ kind: 'literal', value: \"'\" });\n i += 2;\n continue;\n }\n\n // Otherwise this opens a quoted literal span. Scan forward, treating\n // any '' we find *inside* the span as an escaped literal quote rather\n // than the closing delimiter.\n let j = i + 1;\n let literal = '';\n let closed = false;\n while (j < format.length) {\n if (format[j] === \"'\") {\n if (format[j + 1] === \"'\") {\n literal += \"'\";\n j += 2;\n continue;\n }\n closed = true;\n j += 1;\n break;\n }\n literal += format[j];\n j += 1;\n }\n\n if (!closed) {\n throw new Error(`temporal-fmt: unterminated quote in format string \"${format}\"`);\n }\n\n pieces.push({ kind: 'literal', value: literal });\n i = j;\n continue;\n }\n\n const match = SORTED_TOKEN_STRINGS.find((tok) => format.startsWith(tok, i));\n if (match) {\n pieces.push({ kind: 'token', value: match });\n i += match.length;\n continue;\n }\n\n // Not a token, not a quote — pass the character through as-is. This is\n // what lets you write \"yyyy-MM-dd\" with bare hyphens instead of quoting them.\n pieces.push({ kind: 'literal', value: ch });\n i += 1;\n }\n\n return pieces;\n}","import { TOKENS, DEFAULT_LOCALE, type TemporalLike, type FormatOptions } from './tokens.js';\nimport { tokenize } from './tokenize.js';\n\nconst HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));\n\n/**\n * Format a Temporal.PlainDate, PlainTime, PlainDateTime, or ZonedDateTime\n * using a date-fns-style token string.\n *\n * @example\n * format(Temporal.Now.plainDateISO(), 'yyyy-MM-dd') // \"2026-08-04\"\n * format(zdt, \"MMM d, yyyy 'at' h:mm a\") // \"Aug 4, 2026 at 3:45 PM\"\n * format(zdt, 'MMMM d, yyyy', { locale: 'fr-FR' }) // \"août 4, 2026\"\n * format(zdt, 'EEEE d MMMM', { locale: 'ar-EG' }) // Arabic weekday/month names\n *\n * Numeric fields (yyyy, MM, dd, HH, mm, ss, SSS) always render in Western\n * (0-9) digits regardless of locale — this keeps output predictable for\n * anything parsing the result back out (logs, APIs, filenames). Named\n * fields (MMMM, EEEE, a) are fully localized via Intl.DateTimeFormat,\n * including non-Gregorian calendars if the Temporal object itself carries\n * one (e.g. a PlainDate constructed with a Hebrew or Islamic calendar).\n *\n * Throws if the format string uses a token the input type doesn't support\n * (e.g. 'HH' on a PlainDate, which has no time component) — this is\n * deliberate: silently printing \"undefined\" would be worse than failing loudly.\n */\nexport function format(temporal: TemporalLike, formatStr: string, options: FormatOptions = {}): string {\n const locale = options.locale ?? DEFAULT_LOCALE;\n const pieces = tokenize(formatStr);\n let result = '';\n\n for (const piece of pieces) {\n if (piece.kind === 'literal') {\n result += piece.value;\n continue;\n }\n\n const handler = HANDLER_BY_TOKEN.get(piece.value);\n // Shouldn't happen — tokenize() only emits tokens from TOKENS — but keep\n // TypeScript honest and fail loudly instead of silently.\n if (!handler) {\n throw new Error(`temporal-fmt: unknown token \"${piece.value}\"`);\n }\n\n if (temporal[handler.field] === undefined) {\n throw new Error(\n `temporal-fmt: token \"${piece.value}\" requires \"${handler.field}\", ` +\n `which this Temporal object doesn't have. ` +\n `(e.g. PlainDate has no time fields, PlainTime has no date fields)`\n );\n }\n\n result += handler.fn(temporal, locale);\n }\n\n return result;\n}"],"mappings":"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","names":["format"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/tokens.ts","../src/tokenize.ts","../src/format.ts"],"sourcesContent":["export { format } from './format.js';\nexport type { TemporalLike, FormatOptions } from './tokens.js';","// Pad a number with leading zeros to `len` digits.\nexport function pad(n: number, len: number): string {\n return String(n).padStart(len, '0');\n}\n\n// Minimal duck-typed shape covering every field we might read off a Temporal\n// object. Not every field exists on every type (PlainDate has no .hour, for\n// example) — callers check for undefined before formatting a token.\n//\n// calendarId / toInstant are optional because a plain object satisfying\n// this interface in a test won't have them, but real Temporal instances\n// always do — intlPart() below relies on calendarId to keep Intl from\n// rejecting non-Gregorian objects, and on toInstant to detect ZonedDateTime.\nexport interface TemporalLike {\n year?: number;\n month?: number;\n day?: number;\n hour?: number;\n minute?: number;\n second?: number;\n millisecond?: number;\n timeZoneId?: string;\n dayOfWeek?: number; // 1 (Mon) - 7 (Sun), per Temporal spec\n calendarId?: string;\n toInstant?: () => unknown;\n}\n\nexport interface FormatOptions {\n /** BCP 47 locale tag, e.g. 'en-US', 'fr-FR', 'ar-EG'. Defaults to 'en-US'. */\n locale?: string;\n}\n\nexport const DEFAULT_LOCALE = 'en-US';\n\n// Small cache so repeated format() calls with the same (locale, options)\n// pair don't construct a fresh Intl.DateTimeFormat every time — these are\n// somewhat expensive to instantiate and format() may run in a loop (e.g.\n// rendering a table of dates).\nconst formatterCache = new Map<string, Intl.DateTimeFormat>();\n\n// In practice the key space is small — a handful of option shapes (month,\n// weekday, dayPeriod) crossed with however many distinct locales a caller\n// uses — so this is defensive rather than fixing an observed leak. Caps\n// memory for the pathological case of an app looping over many distinct\n// locale strings.\nconst MAX_CACHE_SIZE = 500;\n\nfunction getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = locale + JSON.stringify(options);\n let formatter = formatterCache.get(key);\n if (formatter) {\n return formatter;\n }\n if (formatterCache.size >= MAX_CACHE_SIZE) {\n // Map preserves insertion order, so this evicts the oldest entry —\n // not true LRU, but good enough to bound growth without extra\n // bookkeeping on every cache hit.\n const oldestKey = formatterCache.keys().next().value;\n if (oldestKey !== undefined) formatterCache.delete(oldestKey);\n }\n formatter = new Intl.DateTimeFormat(locale, options);\n formatterCache.set(key, formatter);\n return formatter;\n}\n\n// Reads a single named part (e.g. 'month', 'weekday', 'dayPeriod') out of\n// Intl's formatToParts() output. We ask Intl for exactly one field at a\n// time rather than building a full localized string and slicing it apart —\n// slicing is what breaks under RTL scripts and locales with different\n// field ordering (e.g. year-month-day vs day-month-year).\n//\nfunction intlPart(\n temporal: TemporalLike,\n locale: string,\n options: Intl.DateTimeFormatOptions,\n partType: Intl.DateTimeFormatPartTypes\n): string {\n // Intl.DateTimeFormat.formatToParts() always throws on Temporal.ZonedDateTime\n // specifically — deliberate per spec (see\n // Temporal.ZonedDateTime.prototype.toLocaleString docs), not a bug here.\n // Fix: convert to an Instant and pass the zone through the formatter's own\n // `timeZone` option. Converting to PlainDateTime instead would silently\n // drop the timezone info, which breaks combining e.g. 'MMMM' with 'zzz'.\n const { toInstant, timeZoneId } = temporal;\n const isZoned = typeof toInstant === 'function' && typeof timeZoneId === 'string';\n // Must call as temporal.toInstant(), not the destructured toInstant() —\n // it's a prototype method that reads internal slots off `this`, so\n // calling the bare reference throws \"incompatible receiver undefined\".\n const intlSafeTemporal = isZoned ? temporal.toInstant!() : temporal;\n\n // Intl.DateTimeFormat hard-errors (\"Mismatching Calendars\") if the\n // formatter's resolved calendar doesn't match the Temporal object's own\n // calendar — an 'en-US' formatter defaults to gregory, so feeding it a\n // hebrew- or islamic-calendar PlainDate throws unless the formatter is\n // told which calendar to use. We read the calendar off the object itself\n // rather than guessing from the locale, so whatever calendar the caller's\n // Temporal object carries just works — not only Gregorian.\n //\n // Deliberately NOT passed when calendarId is 'iso8601' (the default for\n // plain Temporal objects nobody explicitly gave a calendar to): passing\n // `calendar: 'iso8601'` explicitly to Intl, combined with a single-field\n // options object like `{ month: 'long' }`, makes formatToParts() return\n // an empty parts array instead of the month — a real quirk in how Intl\n // resolves 'iso8601' with partial options, verified against\n // temporal-polyfill/full. Omitting the option entirely sidesteps it and\n // Intl's own default already matches what an iso8601 object needs.\n const calendar = temporal?.calendarId;\n const formatterOptions: Intl.DateTimeFormatOptions = {\n ...options,\n ...(calendar && calendar !== 'iso8601' ? { calendar } : {}),\n ...(isZoned ? { timeZone: timeZoneId } : {}),\n };\n\n // Real Temporal instances (and the Instant from toInstant() above)\n // satisfy Intl at runtime; TS's lib types just don't model that.\n const formatter = getFormatter(locale, formatterOptions);\n const parts = formatter.formatToParts(intlSafeTemporal as Date | number);\n const part = parts.find((p) => p.type === partType);\n if (!part) {\n throw new Error(\n `temporal-fmt: locale \"${locale}\" produced no \"${partType}\" part for this token. ` +\n `This usually means the Temporal object is missing the field the token needs.`\n );\n }\n return part.value;\n}\n\n// Each token knows how to render itself from a TemporalLike + locale, and\n// which field it depends on (used to validate the input actually has that\n// field before we try to format it).\ntype TokenHandler = (t: TemporalLike, locale: string) => string;\n\n// Longest tokens first — the tokenizer is greedy, so \"yyyy\" must be tried\n// before \"yy\" or it'll never match.\n//\n// Numeric tokens (yyyy, MM, dd, HH, mm, ss, SSS) deliberately always render\n// in Western (0-9) digits regardless of locale, even though Intl could give\n// us locale-native digits (Arabic-Indic, Devanagari, etc. via\n// numberingSystem). Conscious choice, not an oversight: mixing locale\n// numeral systems into our pad()-based width logic is a real rabbit hole\n// (padding \"٣\" to 2 digits isn't the same operation as padding \"3\"), and\n// most consumers parsing these strings back out (logs, APIs, filenames)\n// want predictable ASCII digits. Documented as a known limitation in the\n// README rather than silently guessed at here.\nexport const TOKENS: Array<[string, TokenHandler, keyof TemporalLike]> = [\n ['yyyy', (t) => pad(t.year!, 4), 'year'],\n // Negative years break the fixed 2-digit width (-45 % 100 === -45), and\n // truncating with Math.abs() would make 45 CE and 45 BCE render the same\n // string. Throw instead — use yyyy if you need the sign to survive.\n ['yy', (t) => {\n if (t.year! < 0) {\n throw new Error(\n `temporal-fmt: token \"yy\" doesn't support negative years (got ${t.year}), ` +\n `since truncating to 2 digits would make it indistinguishable from a ` +\n `positive year. Use \"yyyy\" instead.`\n );\n }\n return pad(t.year! % 100, 2);\n }, 'year'],\n ['MMMM', (t, locale) => intlPart(t, locale, { month: 'long' }, 'month'), 'month'],\n ['MMM', (t, locale) => intlPart(t, locale, { month: 'short' }, 'month'), 'month'],\n ['MM', (t) => pad(t.month!, 2), 'month'],\n ['M', (t) => String(t.month!), 'month'],\n ['dd', (t) => pad(t.day!, 2), 'day'],\n ['d', (t) => String(t.day!), 'day'],\n ['EEEE', (t, locale) => intlPart(t, locale, { weekday: 'long' }, 'weekday'), 'dayOfWeek'],\n ['EEE', (t, locale) => intlPart(t, locale, { weekday: 'short' }, 'weekday'), 'dayOfWeek'],\n ['HH', (t) => pad(t.hour!, 2), 'hour'],\n ['H', (t) => String(t.hour!), 'hour'],\n ['hh', (t) => pad(t.hour! % 12 || 12, 2), 'hour'],\n ['h', (t) => String(t.hour! % 12 || 12), 'hour'],\n ['mm', (t) => pad(t.minute!, 2), 'minute'],\n ['m', (t) => String(t.minute!), 'minute'],\n ['ss', (t) => pad(t.second!, 2), 'second'],\n ['s', (t) => String(t.second!), 'second'],\n ['SSS', (t) => pad(t.millisecond!, 3), 'millisecond'],\n // dayPeriod ('AM'/'PM' in en-US, 'م'/'ص' in ar-EG, etc.) is locale-specific\n // — some locales render it differently or don't split 12-hour at all. We\n // still require .hour on the input either way, since that's what Intl\n // needs to compute which period it is.\n ['a', (t, locale) => intlPart(t, locale, { hour: 'numeric', hour12: true }, 'dayPeriod'), 'hour'],\n ['zzz', (t) => t.timeZoneId!, 'timeZoneId'],\n];","import { TOKENS } from './tokens.js';\n\nexport type Piece =\n | { kind: 'token'; value: string }\n | { kind: 'literal'; value: string };\n\n// Sort once, longest-first, so the greedy scanner below never matches \"M\"\n// when \"MMMM\" was actually there.\nconst SORTED_TOKEN_STRINGS = TOKENS.map(([tok]) => tok).sort((a, b) => b.length - a.length);\n\n/**\n * Splits a format string like `\"yyyy-MM-dd 'at' HH:mm\"` into a sequence of\n * token and literal pieces. Text inside single quotes is always literal —\n * that's how you escape a token that would otherwise be parsed (e.g. a\n * literal \"d\" in \"3rd\" — write 'rd' in quotes so it isn't read as the day token).\n * A doubled quote ('') anywhere means a literal single quote character —\n * this works both inside an open quoted span (e.g. 'it''s' -> it's) and\n * as a standalone escape outside one (e.g. yyyy'' -> \"2026'\").\n */\nexport function tokenize(format: string): Piece[] {\n const pieces: Piece[] = [];\n let i = 0;\n\n while (i < format.length) {\n const ch = format[i];\n\n if (ch === \"'\") {\n // Doubled quote is always a literal ' — check this before treating\n // the quote as an open-delimiter, or \"''best''\" gets misread as\n // \"open quote, then bare text, then open quote\" instead of two\n // separate escaped-apostrophe literals around plain text.\n if (format[i + 1] === \"'\") {\n appendLiteral(pieces, \"'\");\n i += 2;\n continue;\n }\n\n // Otherwise this opens a quoted literal span. Scan forward, treating\n // any '' we find *inside* the span as an escaped literal quote rather\n // than the closing delimiter.\n let j = i + 1;\n let literal = '';\n let closed = false;\n while (j < format.length) {\n if (format[j] === \"'\") {\n if (format[j + 1] === \"'\") {\n literal += \"'\";\n j += 2;\n continue;\n }\n closed = true;\n j += 1;\n break;\n }\n literal += format[j];\n j += 1;\n }\n\n if (!closed) {\n throw new Error(`temporal-fmt: unterminated quote in format string \"${format}\"`);\n }\n\n appendLiteral(pieces, literal);\n i = j;\n continue;\n }\n\n const match = SORTED_TOKEN_STRINGS.find((tok) => format.startsWith(tok, i));\n if (match) {\n pieces.push({ kind: 'token', value: match });\n i += match.length;\n continue;\n }\n\n // Not a token, not a quote — pass the character through as-is. This is\n // what lets you write \"yyyy-MM-dd\" with bare hyphens instead of quoting them.\n appendLiteral(pieces, ch);\n i += 1;\n }\n\n return pieces;\n}\n\n// Merges onto the previous piece when it's also a literal, so a run of\n// bare characters (e.g. \"---\" between tokens) becomes one piece instead\n// of one allocation per character.\nfunction appendLiteral(pieces: Piece[], value: string): void {\n const last = pieces[pieces.length - 1];\n if (last && last.kind === 'literal') {\n last.value += value;\n } else {\n pieces.push({ kind: 'literal', value });\n }\n}","import { TOKENS, DEFAULT_LOCALE, type TemporalLike, type FormatOptions } from './tokens.js';\nimport { tokenize } from './tokenize.js';\n\nconst HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));\n\n// Format strings are supposed to be short, hand-written literals like\n// \"yyyy-MM-dd\" — there's no legitimate reason for one to be thousands of\n// characters. Guards against an attacker (or a bug) feeding an enormous\n// string through to tokenize()/format(), which would otherwise scale\n// linearly with input length with no upper bound.\nconst MAX_FORMAT_LENGTH = 1000;\n\n/**\n * Format a Temporal.PlainDate, PlainTime, PlainDateTime, or ZonedDateTime\n * using a date-fns-style token string.\n *\n * @example\n * format(Temporal.Now.plainDateISO(), 'yyyy-MM-dd') // \"2026-08-04\"\n * format(zdt, \"MMM d, yyyy 'at' h:mm a\") // \"Aug 4, 2026 at 3:45 PM\"\n * format(zdt, 'MMMM d, yyyy', { locale: 'fr-FR' }) // \"août 4, 2026\"\n * format(zdt, 'EEEE d MMMM', { locale: 'ar-EG' }) // Arabic weekday/month names\n *\n * Numeric fields (yyyy, MM, dd, HH, mm, ss, SSS) always render in Western\n * (0-9) digits regardless of locale — this keeps output predictable for\n * anything parsing the result back out (logs, APIs, filenames). Named\n * fields (MMMM, EEEE, a) are fully localized via Intl.DateTimeFormat,\n * including non-Gregorian calendars if the Temporal object itself carries\n * one (e.g. a PlainDate constructed with a Hebrew or Islamic calendar).\n *\n * Throws if the format string uses a token the input type doesn't support\n * (e.g. 'HH' on a PlainDate, which has no time component) — this is\n * deliberate: silently printing \"undefined\" would be worse than failing loudly.\n */\nexport function format(temporal: TemporalLike, formatStr: string, options: FormatOptions = {}): string {\n if (formatStr.length > MAX_FORMAT_LENGTH) {\n throw new Error(\n `temporal-fmt: format string exceeds maximum length of ${MAX_FORMAT_LENGTH} characters ` +\n `(got ${formatStr.length}).`\n );\n }\n\n const locale = options.locale ?? DEFAULT_LOCALE;\n const pieces = tokenize(formatStr);\n let result = '';\n\n for (const piece of pieces) {\n if (piece.kind === 'literal') {\n result += piece.value;\n continue;\n }\n\n const handler = HANDLER_BY_TOKEN.get(piece.value);\n // Shouldn't happen — tokenize() only emits tokens from TOKENS — but keep\n // TypeScript honest and fail loudly instead of silently.\n if (!handler) {\n throw new Error(`temporal-fmt: unknown token \"${piece.value}\"`);\n }\n\n if (temporal[handler.field] === undefined) {\n throw new Error(\n `temporal-fmt: token \"${piece.value}\" requires \"${handler.field}\", ` +\n `which this Temporal object doesn't have. ` +\n `(e.g. PlainDate has no time fields, PlainTime has no date fields)`\n );\n }\n\n result += handler.fn(temporal, locale);\n }\n\n return result;\n}"],"mappings":"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","names":["format"]}
package/dist/index.js CHANGED
@@ -4,23 +4,30 @@ function pad(n, len) {
4
4
  }
5
5
  var DEFAULT_LOCALE = "en-US";
6
6
  var formatterCache = /* @__PURE__ */ new Map();
7
+ var MAX_CACHE_SIZE = 500;
7
8
  function getFormatter(locale, options) {
8
9
  const key = locale + JSON.stringify(options);
9
10
  let formatter = formatterCache.get(key);
10
- if (!formatter) {
11
- formatter = new Intl.DateTimeFormat(locale, options);
12
- formatterCache.set(key, formatter);
11
+ if (formatter) {
12
+ return formatter;
13
13
  }
14
+ if (formatterCache.size >= MAX_CACHE_SIZE) {
15
+ const oldestKey = formatterCache.keys().next().value;
16
+ if (oldestKey !== void 0) formatterCache.delete(oldestKey);
17
+ }
18
+ formatter = new Intl.DateTimeFormat(locale, options);
19
+ formatterCache.set(key, formatter);
14
20
  return formatter;
15
21
  }
16
22
  function intlPart(temporal, locale, options, partType) {
17
- const isZoned = typeof temporal?.toInstant === "function" && typeof temporal?.timeZoneId === "string";
23
+ const { toInstant, timeZoneId } = temporal;
24
+ const isZoned = typeof toInstant === "function" && typeof timeZoneId === "string";
18
25
  const intlSafeTemporal = isZoned ? temporal.toInstant() : temporal;
19
26
  const calendar = temporal?.calendarId;
20
27
  const formatterOptions = {
21
28
  ...options,
22
29
  ...calendar && calendar !== "iso8601" ? { calendar } : {},
23
- ...isZoned ? { timeZone: temporal.timeZoneId } : {}
30
+ ...isZoned ? { timeZone: timeZoneId } : {}
24
31
  };
25
32
  const formatter = getFormatter(locale, formatterOptions);
26
33
  const parts = formatter.formatToParts(intlSafeTemporal);
@@ -34,7 +41,17 @@ function intlPart(temporal, locale, options, partType) {
34
41
  }
35
42
  var TOKENS = [
36
43
  ["yyyy", (t) => pad(t.year, 4), "year"],
37
- ["yy", (t) => pad(t.year % 100, 2), "year"],
44
+ // Negative years break the fixed 2-digit width (-45 % 100 === -45), and
45
+ // truncating with Math.abs() would make 45 CE and 45 BCE render the same
46
+ // string. Throw instead — use yyyy if you need the sign to survive.
47
+ ["yy", (t) => {
48
+ if (t.year < 0) {
49
+ throw new Error(
50
+ `temporal-fmt: token "yy" doesn't support negative years (got ${t.year}), since truncating to 2 digits would make it indistinguishable from a positive year. Use "yyyy" instead.`
51
+ );
52
+ }
53
+ return pad(t.year % 100, 2);
54
+ }, "year"],
38
55
  ["MMMM", (t, locale) => intlPart(t, locale, { month: "long" }, "month"), "month"],
39
56
  ["MMM", (t, locale) => intlPart(t, locale, { month: "short" }, "month"), "month"],
40
57
  ["MM", (t) => pad(t.month, 2), "month"],
@@ -69,7 +86,7 @@ function tokenize(format2) {
69
86
  const ch = format2[i];
70
87
  if (ch === "'") {
71
88
  if (format2[i + 1] === "'") {
72
- pieces.push({ kind: "literal", value: "'" });
89
+ appendLiteral(pieces, "'");
73
90
  i += 2;
74
91
  continue;
75
92
  }
@@ -93,7 +110,7 @@ function tokenize(format2) {
93
110
  if (!closed) {
94
111
  throw new Error(`temporal-fmt: unterminated quote in format string "${format2}"`);
95
112
  }
96
- pieces.push({ kind: "literal", value: literal });
113
+ appendLiteral(pieces, literal);
97
114
  i = j;
98
115
  continue;
99
116
  }
@@ -103,15 +120,29 @@ function tokenize(format2) {
103
120
  i += match.length;
104
121
  continue;
105
122
  }
106
- pieces.push({ kind: "literal", value: ch });
123
+ appendLiteral(pieces, ch);
107
124
  i += 1;
108
125
  }
109
126
  return pieces;
110
127
  }
128
+ function appendLiteral(pieces, value) {
129
+ const last = pieces[pieces.length - 1];
130
+ if (last && last.kind === "literal") {
131
+ last.value += value;
132
+ } else {
133
+ pieces.push({ kind: "literal", value });
134
+ }
135
+ }
111
136
 
112
137
  // src/format.ts
113
138
  var HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));
139
+ var MAX_FORMAT_LENGTH = 1e3;
114
140
  function format(temporal, formatStr, options = {}) {
141
+ if (formatStr.length > MAX_FORMAT_LENGTH) {
142
+ throw new Error(
143
+ `temporal-fmt: format string exceeds maximum length of ${MAX_FORMAT_LENGTH} characters (got ${formatStr.length}).`
144
+ );
145
+ }
115
146
  const locale = options.locale ?? DEFAULT_LOCALE;
116
147
  const pieces = tokenize(formatStr);
117
148
  let result = "";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/tokens.ts","../src/tokenize.ts","../src/format.ts"],"sourcesContent":["// Pad a number with leading zeros to `len` digits.\nexport function pad(n: number, len: number): string {\n return String(n).padStart(len, '0');\n}\n\n// Minimal duck-typed shape covering every field we might read off a Temporal\n// object. Not every field exists on every type (PlainDate has no .hour, for\n// example) — callers check for undefined before formatting a token.\n//\n// calendarId / toInstant are optional because a plain object satisfying\n// this interface in a test won't have them, but real Temporal instances\n// always do — intlPart() below relies on calendarId to keep Intl from\n// rejecting non-Gregorian objects, and on toInstant to detect ZonedDateTime.\nexport interface TemporalLike {\n year?: number;\n month?: number;\n day?: number;\n hour?: number;\n minute?: number;\n second?: number;\n millisecond?: number;\n timeZoneId?: string;\n dayOfWeek?: number; // 1 (Mon) - 7 (Sun), per Temporal spec\n calendarId?: string;\n toInstant?: () => unknown;\n}\n\nexport interface FormatOptions {\n /** BCP 47 locale tag, e.g. 'en-US', 'fr-FR', 'ar-EG'. Defaults to 'en-US'. */\n locale?: string;\n}\n\nexport const DEFAULT_LOCALE = 'en-US';\n\n// Small cache so repeated format() calls with the same (locale, options)\n// pair don't construct a fresh Intl.DateTimeFormat every time — these are\n// somewhat expensive to instantiate and format() may run in a loop (e.g.\n// rendering a table of dates).\nconst formatterCache = new Map<string, Intl.DateTimeFormat>();\n\nfunction getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = locale + JSON.stringify(options);\n let formatter = formatterCache.get(key);\n if (!formatter) {\n formatter = new Intl.DateTimeFormat(locale, options);\n formatterCache.set(key, formatter);\n }\n return formatter;\n}\n\n// Reads a single named part (e.g. 'month', 'weekday', 'dayPeriod') out of\n// Intl's formatToParts() output. We ask Intl for exactly one field at a\n// time rather than building a full localized string and slicing it apart —\n// slicing is what breaks under RTL scripts and locales with different\n// field ordering (e.g. year-month-day vs day-month-year).\n//\n// `temporal: any` — deliberately untyped. Real Temporal.PlainDate /\n// PlainDateTime / ZonedDateTime instances (not the TemporalLike interface)\n// are what get passed through to Intl at runtime.\nfunction intlPart(\n temporal: any,\n locale: string,\n options: Intl.DateTimeFormatOptions,\n partType: Intl.DateTimeFormatPartTypes\n): string {\n // Intl.DateTimeFormat.formatToParts() always throws on Temporal.ZonedDateTime\n // specifically — deliberate per spec (see\n // Temporal.ZonedDateTime.prototype.toLocaleString docs), not a bug here.\n // Fix: convert to an Instant and pass the zone through the formatter's own\n // `timeZone` option. Converting to PlainDateTime instead would silently\n // drop the timezone info, which breaks combining e.g. 'MMMM' with 'zzz'.\n const isZoned = typeof temporal?.toInstant === 'function' && typeof temporal?.timeZoneId === 'string';\n const intlSafeTemporal = isZoned ? temporal.toInstant() : temporal;\n\n // Intl.DateTimeFormat hard-errors (\"Mismatching Calendars\") if the\n // formatter's resolved calendar doesn't match the Temporal object's own\n // calendar — an 'en-US' formatter defaults to gregory, so feeding it a\n // hebrew- or islamic-calendar PlainDate throws unless the formatter is\n // told which calendar to use. We read the calendar off the object itself\n // rather than guessing from the locale, so whatever calendar the caller's\n // Temporal object carries just works — not only Gregorian.\n //\n // Deliberately NOT passed when calendarId is 'iso8601' (the default for\n // plain Temporal objects nobody explicitly gave a calendar to): passing\n // `calendar: 'iso8601'` explicitly to Intl, combined with a single-field\n // options object like `{ month: 'long' }`, makes formatToParts() return\n // an empty parts array instead of the month — a real quirk in how Intl\n // resolves 'iso8601' with partial options, verified against\n // temporal-polyfill/full. Omitting the option entirely sidesteps it and\n // Intl's own default already matches what an iso8601 object needs.\n const calendar = temporal?.calendarId;\n const formatterOptions: Intl.DateTimeFormatOptions = {\n ...options,\n ...(calendar && calendar !== 'iso8601' ? { calendar } : {}),\n ...(isZoned ? { timeZone: temporal.timeZoneId } : {}),\n };\n\n const formatter = getFormatter(locale, formatterOptions);\n const parts = formatter.formatToParts(intlSafeTemporal);\n const part = parts.find((p) => p.type === partType);\n if (!part) {\n throw new Error(\n `temporal-fmt: locale \"${locale}\" produced no \"${partType}\" part for this token. ` +\n `This usually means the Temporal object is missing the field the token needs.`\n );\n }\n return part.value;\n}\n\n// Each token knows how to render itself from a TemporalLike + locale, and\n// which field it depends on (used to validate the input actually has that\n// field before we try to format it).\ntype TokenHandler = (t: TemporalLike, locale: string) => string;\n\n// Longest tokens first — the tokenizer is greedy, so \"yyyy\" must be tried\n// before \"yy\" or it'll never match.\n//\n// Numeric tokens (yyyy, MM, dd, HH, mm, ss, SSS) deliberately always render\n// in Western (0-9) digits regardless of locale, even though Intl could give\n// us locale-native digits (Arabic-Indic, Devanagari, etc. via\n// numberingSystem). Conscious choice, not an oversight: mixing locale\n// numeral systems into our pad()-based width logic is a real rabbit hole\n// (padding \"٣\" to 2 digits isn't the same operation as padding \"3\"), and\n// most consumers parsing these strings back out (logs, APIs, filenames)\n// want predictable ASCII digits. Documented as a known limitation in the\n// README rather than silently guessed at here.\nexport const TOKENS: Array<[string, TokenHandler, keyof TemporalLike]> = [\n ['yyyy', (t) => pad(t.year!, 4), 'year'],\n ['yy', (t) => pad(t.year! % 100, 2), 'year'],\n ['MMMM', (t, locale) => intlPart(t, locale, { month: 'long' }, 'month'), 'month'],\n ['MMM', (t, locale) => intlPart(t, locale, { month: 'short' }, 'month'), 'month'],\n ['MM', (t) => pad(t.month!, 2), 'month'],\n ['M', (t) => String(t.month!), 'month'],\n ['dd', (t) => pad(t.day!, 2), 'day'],\n ['d', (t) => String(t.day!), 'day'],\n ['EEEE', (t, locale) => intlPart(t, locale, { weekday: 'long' }, 'weekday'), 'dayOfWeek'],\n ['EEE', (t, locale) => intlPart(t, locale, { weekday: 'short' }, 'weekday'), 'dayOfWeek'],\n ['HH', (t) => pad(t.hour!, 2), 'hour'],\n ['H', (t) => String(t.hour!), 'hour'],\n ['hh', (t) => pad(t.hour! % 12 || 12, 2), 'hour'],\n ['h', (t) => String(t.hour! % 12 || 12), 'hour'],\n ['mm', (t) => pad(t.minute!, 2), 'minute'],\n ['m', (t) => String(t.minute!), 'minute'],\n ['ss', (t) => pad(t.second!, 2), 'second'],\n ['s', (t) => String(t.second!), 'second'],\n ['SSS', (t) => pad(t.millisecond!, 3), 'millisecond'],\n // dayPeriod ('AM'/'PM' in en-US, 'م'/'ص' in ar-EG, etc.) is locale-specific\n // — some locales render it differently or don't split 12-hour at all. We\n // still require .hour on the input either way, since that's what Intl\n // needs to compute which period it is.\n ['a', (t, locale) => intlPart(t, locale, { hour: 'numeric', hour12: true }, 'dayPeriod'), 'hour'],\n ['zzz', (t) => t.timeZoneId!, 'timeZoneId'],\n];","import { TOKENS } from './tokens.js';\n\nexport type Piece =\n | { kind: 'token'; value: string }\n | { kind: 'literal'; value: string };\n\n// Sort once, longest-first, so the greedy scanner below never matches \"M\"\n// when \"MMMM\" was actually there.\nconst SORTED_TOKEN_STRINGS = TOKENS.map(([tok]) => tok).sort((a, b) => b.length - a.length);\n\n/**\n * Splits a format string like `\"yyyy-MM-dd 'at' HH:mm\"` into a sequence of\n * token and literal pieces. Text inside single quotes is always literal —\n * that's how you escape a token that would otherwise be parsed (e.g. a\n * literal \"d\" in \"3rd\" — write 'rd' in quotes so it isn't read as the day token).\n * A doubled quote ('') anywhere means a literal single quote character —\n * this works both inside an open quoted span (e.g. 'it''s' -> it's) and\n * as a standalone escape outside one (e.g. yyyy'' -> \"2026'\").\n */\nexport function tokenize(format: string): Piece[] {\n const pieces: Piece[] = [];\n let i = 0;\n\n while (i < format.length) {\n const ch = format[i];\n\n if (ch === \"'\") {\n // Doubled quote is always a literal ' — check this before treating\n // the quote as an open-delimiter, or \"''best''\" gets misread as\n // \"open quote, then bare text, then open quote\" instead of two\n // separate escaped-apostrophe literals around plain text.\n if (format[i + 1] === \"'\") {\n pieces.push({ kind: 'literal', value: \"'\" });\n i += 2;\n continue;\n }\n\n // Otherwise this opens a quoted literal span. Scan forward, treating\n // any '' we find *inside* the span as an escaped literal quote rather\n // than the closing delimiter.\n let j = i + 1;\n let literal = '';\n let closed = false;\n while (j < format.length) {\n if (format[j] === \"'\") {\n if (format[j + 1] === \"'\") {\n literal += \"'\";\n j += 2;\n continue;\n }\n closed = true;\n j += 1;\n break;\n }\n literal += format[j];\n j += 1;\n }\n\n if (!closed) {\n throw new Error(`temporal-fmt: unterminated quote in format string \"${format}\"`);\n }\n\n pieces.push({ kind: 'literal', value: literal });\n i = j;\n continue;\n }\n\n const match = SORTED_TOKEN_STRINGS.find((tok) => format.startsWith(tok, i));\n if (match) {\n pieces.push({ kind: 'token', value: match });\n i += match.length;\n continue;\n }\n\n // Not a token, not a quote — pass the character through as-is. This is\n // what lets you write \"yyyy-MM-dd\" with bare hyphens instead of quoting them.\n pieces.push({ kind: 'literal', value: ch });\n i += 1;\n }\n\n return pieces;\n}","import { TOKENS, DEFAULT_LOCALE, type TemporalLike, type FormatOptions } from './tokens.js';\nimport { tokenize } from './tokenize.js';\n\nconst HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));\n\n/**\n * Format a Temporal.PlainDate, PlainTime, PlainDateTime, or ZonedDateTime\n * using a date-fns-style token string.\n *\n * @example\n * format(Temporal.Now.plainDateISO(), 'yyyy-MM-dd') // \"2026-08-04\"\n * format(zdt, \"MMM d, yyyy 'at' h:mm a\") // \"Aug 4, 2026 at 3:45 PM\"\n * format(zdt, 'MMMM d, yyyy', { locale: 'fr-FR' }) // \"août 4, 2026\"\n * format(zdt, 'EEEE d MMMM', { locale: 'ar-EG' }) // Arabic weekday/month names\n *\n * Numeric fields (yyyy, MM, dd, HH, mm, ss, SSS) always render in Western\n * (0-9) digits regardless of locale — this keeps output predictable for\n * anything parsing the result back out (logs, APIs, filenames). Named\n * fields (MMMM, EEEE, a) are fully localized via Intl.DateTimeFormat,\n * including non-Gregorian calendars if the Temporal object itself carries\n * one (e.g. a PlainDate constructed with a Hebrew or Islamic calendar).\n *\n * Throws if the format string uses a token the input type doesn't support\n * (e.g. 'HH' on a PlainDate, which has no time component) — this is\n * deliberate: silently printing \"undefined\" would be worse than failing loudly.\n */\nexport function format(temporal: TemporalLike, formatStr: string, options: FormatOptions = {}): string {\n const locale = options.locale ?? DEFAULT_LOCALE;\n const pieces = tokenize(formatStr);\n let result = '';\n\n for (const piece of pieces) {\n if (piece.kind === 'literal') {\n result += piece.value;\n continue;\n }\n\n const handler = HANDLER_BY_TOKEN.get(piece.value);\n // Shouldn't happen — tokenize() only emits tokens from TOKENS — but keep\n // TypeScript honest and fail loudly instead of silently.\n if (!handler) {\n throw new Error(`temporal-fmt: unknown token \"${piece.value}\"`);\n }\n\n if (temporal[handler.field] === undefined) {\n throw new Error(\n `temporal-fmt: token \"${piece.value}\" requires \"${handler.field}\", ` +\n `which this Temporal object doesn't have. ` +\n `(e.g. PlainDate has no time fields, PlainTime has no date fields)`\n );\n }\n\n result += handler.fn(temporal, locale);\n }\n\n return result;\n}"],"mappings":"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","names":["format"]}
1
+ {"version":3,"sources":["../src/tokens.ts","../src/tokenize.ts","../src/format.ts"],"sourcesContent":["// Pad a number with leading zeros to `len` digits.\nexport function pad(n: number, len: number): string {\n return String(n).padStart(len, '0');\n}\n\n// Minimal duck-typed shape covering every field we might read off a Temporal\n// object. Not every field exists on every type (PlainDate has no .hour, for\n// example) — callers check for undefined before formatting a token.\n//\n// calendarId / toInstant are optional because a plain object satisfying\n// this interface in a test won't have them, but real Temporal instances\n// always do — intlPart() below relies on calendarId to keep Intl from\n// rejecting non-Gregorian objects, and on toInstant to detect ZonedDateTime.\nexport interface TemporalLike {\n year?: number;\n month?: number;\n day?: number;\n hour?: number;\n minute?: number;\n second?: number;\n millisecond?: number;\n timeZoneId?: string;\n dayOfWeek?: number; // 1 (Mon) - 7 (Sun), per Temporal spec\n calendarId?: string;\n toInstant?: () => unknown;\n}\n\nexport interface FormatOptions {\n /** BCP 47 locale tag, e.g. 'en-US', 'fr-FR', 'ar-EG'. Defaults to 'en-US'. */\n locale?: string;\n}\n\nexport const DEFAULT_LOCALE = 'en-US';\n\n// Small cache so repeated format() calls with the same (locale, options)\n// pair don't construct a fresh Intl.DateTimeFormat every time — these are\n// somewhat expensive to instantiate and format() may run in a loop (e.g.\n// rendering a table of dates).\nconst formatterCache = new Map<string, Intl.DateTimeFormat>();\n\n// In practice the key space is small — a handful of option shapes (month,\n// weekday, dayPeriod) crossed with however many distinct locales a caller\n// uses — so this is defensive rather than fixing an observed leak. Caps\n// memory for the pathological case of an app looping over many distinct\n// locale strings.\nconst MAX_CACHE_SIZE = 500;\n\nfunction getFormatter(locale: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = locale + JSON.stringify(options);\n let formatter = formatterCache.get(key);\n if (formatter) {\n return formatter;\n }\n if (formatterCache.size >= MAX_CACHE_SIZE) {\n // Map preserves insertion order, so this evicts the oldest entry —\n // not true LRU, but good enough to bound growth without extra\n // bookkeeping on every cache hit.\n const oldestKey = formatterCache.keys().next().value;\n if (oldestKey !== undefined) formatterCache.delete(oldestKey);\n }\n formatter = new Intl.DateTimeFormat(locale, options);\n formatterCache.set(key, formatter);\n return formatter;\n}\n\n// Reads a single named part (e.g. 'month', 'weekday', 'dayPeriod') out of\n// Intl's formatToParts() output. We ask Intl for exactly one field at a\n// time rather than building a full localized string and slicing it apart —\n// slicing is what breaks under RTL scripts and locales with different\n// field ordering (e.g. year-month-day vs day-month-year).\n//\nfunction intlPart(\n temporal: TemporalLike,\n locale: string,\n options: Intl.DateTimeFormatOptions,\n partType: Intl.DateTimeFormatPartTypes\n): string {\n // Intl.DateTimeFormat.formatToParts() always throws on Temporal.ZonedDateTime\n // specifically — deliberate per spec (see\n // Temporal.ZonedDateTime.prototype.toLocaleString docs), not a bug here.\n // Fix: convert to an Instant and pass the zone through the formatter's own\n // `timeZone` option. Converting to PlainDateTime instead would silently\n // drop the timezone info, which breaks combining e.g. 'MMMM' with 'zzz'.\n const { toInstant, timeZoneId } = temporal;\n const isZoned = typeof toInstant === 'function' && typeof timeZoneId === 'string';\n // Must call as temporal.toInstant(), not the destructured toInstant() —\n // it's a prototype method that reads internal slots off `this`, so\n // calling the bare reference throws \"incompatible receiver undefined\".\n const intlSafeTemporal = isZoned ? temporal.toInstant!() : temporal;\n\n // Intl.DateTimeFormat hard-errors (\"Mismatching Calendars\") if the\n // formatter's resolved calendar doesn't match the Temporal object's own\n // calendar — an 'en-US' formatter defaults to gregory, so feeding it a\n // hebrew- or islamic-calendar PlainDate throws unless the formatter is\n // told which calendar to use. We read the calendar off the object itself\n // rather than guessing from the locale, so whatever calendar the caller's\n // Temporal object carries just works — not only Gregorian.\n //\n // Deliberately NOT passed when calendarId is 'iso8601' (the default for\n // plain Temporal objects nobody explicitly gave a calendar to): passing\n // `calendar: 'iso8601'` explicitly to Intl, combined with a single-field\n // options object like `{ month: 'long' }`, makes formatToParts() return\n // an empty parts array instead of the month — a real quirk in how Intl\n // resolves 'iso8601' with partial options, verified against\n // temporal-polyfill/full. Omitting the option entirely sidesteps it and\n // Intl's own default already matches what an iso8601 object needs.\n const calendar = temporal?.calendarId;\n const formatterOptions: Intl.DateTimeFormatOptions = {\n ...options,\n ...(calendar && calendar !== 'iso8601' ? { calendar } : {}),\n ...(isZoned ? { timeZone: timeZoneId } : {}),\n };\n\n // Real Temporal instances (and the Instant from toInstant() above)\n // satisfy Intl at runtime; TS's lib types just don't model that.\n const formatter = getFormatter(locale, formatterOptions);\n const parts = formatter.formatToParts(intlSafeTemporal as Date | number);\n const part = parts.find((p) => p.type === partType);\n if (!part) {\n throw new Error(\n `temporal-fmt: locale \"${locale}\" produced no \"${partType}\" part for this token. ` +\n `This usually means the Temporal object is missing the field the token needs.`\n );\n }\n return part.value;\n}\n\n// Each token knows how to render itself from a TemporalLike + locale, and\n// which field it depends on (used to validate the input actually has that\n// field before we try to format it).\ntype TokenHandler = (t: TemporalLike, locale: string) => string;\n\n// Longest tokens first — the tokenizer is greedy, so \"yyyy\" must be tried\n// before \"yy\" or it'll never match.\n//\n// Numeric tokens (yyyy, MM, dd, HH, mm, ss, SSS) deliberately always render\n// in Western (0-9) digits regardless of locale, even though Intl could give\n// us locale-native digits (Arabic-Indic, Devanagari, etc. via\n// numberingSystem). Conscious choice, not an oversight: mixing locale\n// numeral systems into our pad()-based width logic is a real rabbit hole\n// (padding \"٣\" to 2 digits isn't the same operation as padding \"3\"), and\n// most consumers parsing these strings back out (logs, APIs, filenames)\n// want predictable ASCII digits. Documented as a known limitation in the\n// README rather than silently guessed at here.\nexport const TOKENS: Array<[string, TokenHandler, keyof TemporalLike]> = [\n ['yyyy', (t) => pad(t.year!, 4), 'year'],\n // Negative years break the fixed 2-digit width (-45 % 100 === -45), and\n // truncating with Math.abs() would make 45 CE and 45 BCE render the same\n // string. Throw instead — use yyyy if you need the sign to survive.\n ['yy', (t) => {\n if (t.year! < 0) {\n throw new Error(\n `temporal-fmt: token \"yy\" doesn't support negative years (got ${t.year}), ` +\n `since truncating to 2 digits would make it indistinguishable from a ` +\n `positive year. Use \"yyyy\" instead.`\n );\n }\n return pad(t.year! % 100, 2);\n }, 'year'],\n ['MMMM', (t, locale) => intlPart(t, locale, { month: 'long' }, 'month'), 'month'],\n ['MMM', (t, locale) => intlPart(t, locale, { month: 'short' }, 'month'), 'month'],\n ['MM', (t) => pad(t.month!, 2), 'month'],\n ['M', (t) => String(t.month!), 'month'],\n ['dd', (t) => pad(t.day!, 2), 'day'],\n ['d', (t) => String(t.day!), 'day'],\n ['EEEE', (t, locale) => intlPart(t, locale, { weekday: 'long' }, 'weekday'), 'dayOfWeek'],\n ['EEE', (t, locale) => intlPart(t, locale, { weekday: 'short' }, 'weekday'), 'dayOfWeek'],\n ['HH', (t) => pad(t.hour!, 2), 'hour'],\n ['H', (t) => String(t.hour!), 'hour'],\n ['hh', (t) => pad(t.hour! % 12 || 12, 2), 'hour'],\n ['h', (t) => String(t.hour! % 12 || 12), 'hour'],\n ['mm', (t) => pad(t.minute!, 2), 'minute'],\n ['m', (t) => String(t.minute!), 'minute'],\n ['ss', (t) => pad(t.second!, 2), 'second'],\n ['s', (t) => String(t.second!), 'second'],\n ['SSS', (t) => pad(t.millisecond!, 3), 'millisecond'],\n // dayPeriod ('AM'/'PM' in en-US, 'م'/'ص' in ar-EG, etc.) is locale-specific\n // — some locales render it differently or don't split 12-hour at all. We\n // still require .hour on the input either way, since that's what Intl\n // needs to compute which period it is.\n ['a', (t, locale) => intlPart(t, locale, { hour: 'numeric', hour12: true }, 'dayPeriod'), 'hour'],\n ['zzz', (t) => t.timeZoneId!, 'timeZoneId'],\n];","import { TOKENS } from './tokens.js';\n\nexport type Piece =\n | { kind: 'token'; value: string }\n | { kind: 'literal'; value: string };\n\n// Sort once, longest-first, so the greedy scanner below never matches \"M\"\n// when \"MMMM\" was actually there.\nconst SORTED_TOKEN_STRINGS = TOKENS.map(([tok]) => tok).sort((a, b) => b.length - a.length);\n\n/**\n * Splits a format string like `\"yyyy-MM-dd 'at' HH:mm\"` into a sequence of\n * token and literal pieces. Text inside single quotes is always literal —\n * that's how you escape a token that would otherwise be parsed (e.g. a\n * literal \"d\" in \"3rd\" — write 'rd' in quotes so it isn't read as the day token).\n * A doubled quote ('') anywhere means a literal single quote character —\n * this works both inside an open quoted span (e.g. 'it''s' -> it's) and\n * as a standalone escape outside one (e.g. yyyy'' -> \"2026'\").\n */\nexport function tokenize(format: string): Piece[] {\n const pieces: Piece[] = [];\n let i = 0;\n\n while (i < format.length) {\n const ch = format[i];\n\n if (ch === \"'\") {\n // Doubled quote is always a literal ' — check this before treating\n // the quote as an open-delimiter, or \"''best''\" gets misread as\n // \"open quote, then bare text, then open quote\" instead of two\n // separate escaped-apostrophe literals around plain text.\n if (format[i + 1] === \"'\") {\n appendLiteral(pieces, \"'\");\n i += 2;\n continue;\n }\n\n // Otherwise this opens a quoted literal span. Scan forward, treating\n // any '' we find *inside* the span as an escaped literal quote rather\n // than the closing delimiter.\n let j = i + 1;\n let literal = '';\n let closed = false;\n while (j < format.length) {\n if (format[j] === \"'\") {\n if (format[j + 1] === \"'\") {\n literal += \"'\";\n j += 2;\n continue;\n }\n closed = true;\n j += 1;\n break;\n }\n literal += format[j];\n j += 1;\n }\n\n if (!closed) {\n throw new Error(`temporal-fmt: unterminated quote in format string \"${format}\"`);\n }\n\n appendLiteral(pieces, literal);\n i = j;\n continue;\n }\n\n const match = SORTED_TOKEN_STRINGS.find((tok) => format.startsWith(tok, i));\n if (match) {\n pieces.push({ kind: 'token', value: match });\n i += match.length;\n continue;\n }\n\n // Not a token, not a quote — pass the character through as-is. This is\n // what lets you write \"yyyy-MM-dd\" with bare hyphens instead of quoting them.\n appendLiteral(pieces, ch);\n i += 1;\n }\n\n return pieces;\n}\n\n// Merges onto the previous piece when it's also a literal, so a run of\n// bare characters (e.g. \"---\" between tokens) becomes one piece instead\n// of one allocation per character.\nfunction appendLiteral(pieces: Piece[], value: string): void {\n const last = pieces[pieces.length - 1];\n if (last && last.kind === 'literal') {\n last.value += value;\n } else {\n pieces.push({ kind: 'literal', value });\n }\n}","import { TOKENS, DEFAULT_LOCALE, type TemporalLike, type FormatOptions } from './tokens.js';\nimport { tokenize } from './tokenize.js';\n\nconst HANDLER_BY_TOKEN = new Map(TOKENS.map(([tok, fn, field]) => [tok, { fn, field }]));\n\n// Format strings are supposed to be short, hand-written literals like\n// \"yyyy-MM-dd\" — there's no legitimate reason for one to be thousands of\n// characters. Guards against an attacker (or a bug) feeding an enormous\n// string through to tokenize()/format(), which would otherwise scale\n// linearly with input length with no upper bound.\nconst MAX_FORMAT_LENGTH = 1000;\n\n/**\n * Format a Temporal.PlainDate, PlainTime, PlainDateTime, or ZonedDateTime\n * using a date-fns-style token string.\n *\n * @example\n * format(Temporal.Now.plainDateISO(), 'yyyy-MM-dd') // \"2026-08-04\"\n * format(zdt, \"MMM d, yyyy 'at' h:mm a\") // \"Aug 4, 2026 at 3:45 PM\"\n * format(zdt, 'MMMM d, yyyy', { locale: 'fr-FR' }) // \"août 4, 2026\"\n * format(zdt, 'EEEE d MMMM', { locale: 'ar-EG' }) // Arabic weekday/month names\n *\n * Numeric fields (yyyy, MM, dd, HH, mm, ss, SSS) always render in Western\n * (0-9) digits regardless of locale — this keeps output predictable for\n * anything parsing the result back out (logs, APIs, filenames). Named\n * fields (MMMM, EEEE, a) are fully localized via Intl.DateTimeFormat,\n * including non-Gregorian calendars if the Temporal object itself carries\n * one (e.g. a PlainDate constructed with a Hebrew or Islamic calendar).\n *\n * Throws if the format string uses a token the input type doesn't support\n * (e.g. 'HH' on a PlainDate, which has no time component) — this is\n * deliberate: silently printing \"undefined\" would be worse than failing loudly.\n */\nexport function format(temporal: TemporalLike, formatStr: string, options: FormatOptions = {}): string {\n if (formatStr.length > MAX_FORMAT_LENGTH) {\n throw new Error(\n `temporal-fmt: format string exceeds maximum length of ${MAX_FORMAT_LENGTH} characters ` +\n `(got ${formatStr.length}).`\n );\n }\n\n const locale = options.locale ?? DEFAULT_LOCALE;\n const pieces = tokenize(formatStr);\n let result = '';\n\n for (const piece of pieces) {\n if (piece.kind === 'literal') {\n result += piece.value;\n continue;\n }\n\n const handler = HANDLER_BY_TOKEN.get(piece.value);\n // Shouldn't happen — tokenize() only emits tokens from TOKENS — but keep\n // TypeScript honest and fail loudly instead of silently.\n if (!handler) {\n throw new Error(`temporal-fmt: unknown token \"${piece.value}\"`);\n }\n\n if (temporal[handler.field] === undefined) {\n throw new Error(\n `temporal-fmt: token \"${piece.value}\" requires \"${handler.field}\", ` +\n `which this Temporal object doesn't have. ` +\n `(e.g. PlainDate has no time fields, PlainTime has no date fields)`\n );\n }\n\n result += handler.fn(temporal, locale);\n }\n\n return result;\n}"],"mappings":"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","names":["format"]}
package/package.json CHANGED
@@ -1,60 +1,60 @@
1
- {
2
- "name": "temporal-fmt",
3
- "version": "0.2.4",
4
- "description": "Format Temporal.PlainDate/PlainDateTime/PlainTime/ZonedDateTime objects using date-fns-style token strings.",
5
- "type": "module",
6
- "main": "./dist/index.cjs",
7
- "module": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "exports": {
10
- ".": {
11
- "types": "./dist/index.d.ts",
12
- "import": "./dist/index.js",
13
- "require": "./dist/index.cjs"
14
- }
15
- },
16
- "files": [
17
- "dist"
18
- ],
19
- "sideEffects": false,
20
- "scripts": {
21
- "build": "tsup",
22
- "dev": "tsup --watch",
23
- "test": "node --test test/*.test.js",
24
- "prepublishOnly": "npm run build && npm test"
25
- },
26
- "keywords": [
27
- "temporal",
28
- "date",
29
- "time",
30
- "format",
31
- "tc39"
32
- ],
33
- "license": "MIT",
34
- "author": {
35
- "name": "NovaByte Official",
36
- "url": "https://github.com/NovaByteOfficial"
37
- },
38
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/NovaByteOfficial/temporal-fmt.git"
41
- },
42
- "bugs": {
43
- "url": "https://github.com/NovaByteOfficial/temporal-fmt/issues"
44
- },
45
- "homepage": "https://github.com/NovaByteOfficial/temporal-fmt#readme",
46
- "devDependencies": {
47
- "temporal-polyfill": "^1.0.3",
48
- "tsup": "^8.5.1",
49
- "typescript": "^6.0.3"
50
- },
51
- "overrides": {
52
- "esbuild": "0.28.1"
53
- },
54
- "engines": {
55
- "node": ">=24"
56
- },
57
- "allowScripts": {
58
- "esbuild@0.28.1": true
59
- }
60
- }
1
+ {
2
+ "name": "temporal-fmt",
3
+ "version": "0.3.0",
4
+ "description": "Format Temporal.PlainDate/PlainDateTime/PlainTime/ZonedDateTime objects using date-fns-style token strings.",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "sideEffects": false,
20
+ "scripts": {
21
+ "build": "tsup",
22
+ "dev": "tsup --watch",
23
+ "test": "node --test test/*.test.js",
24
+ "prepublishOnly": "npm run build && npm test"
25
+ },
26
+ "keywords": [
27
+ "temporal",
28
+ "date",
29
+ "time",
30
+ "format",
31
+ "tc39"
32
+ ],
33
+ "license": "MIT",
34
+ "author": {
35
+ "name": "NovaByte Official",
36
+ "url": "https://github.com/NovaByteOfficial"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/NovaByteOfficial/temporal-fmt.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/NovaByteOfficial/temporal-fmt/issues"
44
+ },
45
+ "homepage": "https://github.com/NovaByteOfficial/temporal-fmt#readme",
46
+ "devDependencies": {
47
+ "temporal-polyfill": "1.0.3",
48
+ "tsup": "8.5.1",
49
+ "typescript": "6.0.3"
50
+ },
51
+ "overrides": {
52
+ "esbuild": "0.28.1"
53
+ },
54
+ "engines": {
55
+ "node": ">=24"
56
+ },
57
+ "allowScripts": {
58
+ "esbuild@0.28.1": true
59
+ }
60
+ }