Skip to main content
Home
This release is 30 versions behind 0.225.6 — the latest version of @std/datetime. Jump to latest
Works with
This package works with Cloudflare Workers, Node.js, Deno, Bun, Browsers
This package works with Cloudflare Workers
This package works with Node.js
This package works with Deno
This package works with Bun
This package works with Browsers
JSR Score100%
Downloads2,857/wk
Published2 years ago (0.211.0)

UNSTABLE: Utilities for dealing with Date objects

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. export type Token = { type: string; value: string | number; index: number; [key: string]: unknown; }; export interface ReceiverResult { [name: string]: string | number | unknown; } export type CallbackResult = { type: string; value: string | number; [key: string]: unknown; }; type CallbackFunction = (value: unknown) => CallbackResult; export type TestResult = { value: unknown; length: number } | undefined; export type TestFunction = ( string: string, ) => TestResult | undefined; export interface Rule { test: TestFunction; fn: CallbackFunction; } export class Tokenizer { rules: Rule[]; constructor(rules: Rule[] = []) { this.rules = rules; } addRule(test: TestFunction, fn: CallbackFunction): Tokenizer { this.rules.push({ test, fn }); return this; } tokenize( string: string, receiver = (token: Token): ReceiverResult => token, ): ReceiverResult[] { function* generator(rules: Rule[]): IterableIterator<ReceiverResult> { let index = 0; for (const rule of rules) { const result = rule.test(string); if (result) { const { value, length } = result; index += length; string = string.slice(length); const token = { ...rule.fn(value), index }; yield receiver(token); yield* generator(rules); } } } const tokenGenerator = generator(this.rules); const tokens: ReceiverResult[] = []; for (const token of tokenGenerator) { tokens.push(token); } if (string.length) { throw new Error( `parser error: string not fully parsed! ${string.slice(0, 25)}`, ); } return tokens; } } function digits(value: string | number, count = 2): string { return String(value).padStart(count, "0"); } // as declared as in namespace Intl type DateTimeFormatPartTypes = | "day" | "dayPeriod" // | "era" | "hour" | "literal" | "minute" | "month" | "second" | "timeZoneName" // | "weekday" | "year" | "fractionalSecond"; interface DateTimeFormatPart { type: DateTimeFormatPartTypes; value: string; } type TimeZone = "UTC"; interface Options { timeZone?: TimeZone; } function createLiteralTestFunction(value: string): TestFunction { return (string: string): TestResult => { return string.startsWith(value) ? { value, length: value.length } : undefined; }; } function createMatchTestFunction(match: RegExp): TestFunction { return (string: string): TestResult => { const result = match.exec(string); if (result) return { value: result, length: result[0].length }; }; } // according to unicode symbols (http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) const defaultRules = [ { test: createLiteralTestFunction("yyyy"), fn: (): CallbackResult => ({ type: "year", value: "numeric" }), }, { test: createLiteralTestFunction("yy"), fn: (): CallbackResult => ({ type: "year", value: "2-digit" }), }, { test: createLiteralTestFunction("MM"), fn: (): CallbackResult => ({ type: "month", value: "2-digit" }), }, { test: createLiteralTestFunction("M"), fn: (): CallbackResult => ({ type: "month", value: "numeric" }), }, { test: createLiteralTestFunction("dd"), fn: (): CallbackResult => ({ type: "day", value: "2-digit" }), }, { test: createLiteralTestFunction("d"), fn: (): CallbackResult => ({ type: "day", value: "numeric" }), }, { test: createLiteralTestFunction("HH"), fn: (): CallbackResult => ({ type: "hour", value: "2-digit" }), }, { test: createLiteralTestFunction("H"), fn: (): CallbackResult => ({ type: "hour", value: "numeric" }), }, { test: createLiteralTestFunction("hh"), fn: (): CallbackResult => ({ type: "hour", value: "2-digit", hour12: true, }), }, { test: createLiteralTestFunction("h"), fn: (): CallbackResult => ({ type: "hour", value: "numeric", hour12: true, }), }, { test: createLiteralTestFunction("mm"), fn: (): CallbackResult => ({ type: "minute", value: "2-digit" }), }, { test: createLiteralTestFunction("m"), fn: (): CallbackResult => ({ type: "minute", value: "numeric" }), }, { test: createLiteralTestFunction("ss"), fn: (): CallbackResult => ({ type: "second", value: "2-digit" }), }, { test: createLiteralTestFunction("s"), fn: (): CallbackResult => ({ type: "second", value: "numeric" }), }, { test: createLiteralTestFunction("SSS"), fn: (): CallbackResult => ({ type: "fractionalSecond", value: 3 }), }, { test: createLiteralTestFunction("SS"), fn: (): CallbackResult => ({ type: "fractionalSecond", value: 2 }), }, { test: createLiteralTestFunction("S"), fn: (): CallbackResult => ({ type: "fractionalSecond", value: 1 }), }, { test: createLiteralTestFunction("a"), fn: (value: unknown): CallbackResult => ({ type: "dayPeriod", value: value as string, }), }, // quoted literal { test: createMatchTestFunction(/^(')(?<value>\\.|[^\']*)\1/), fn: (match: unknown): CallbackResult => ({ type: "literal", value: (match as RegExpExecArray).groups!.value as string, }), }, // literal { test: createMatchTestFunction(/^.+?\s*/), fn: (match: unknown): CallbackResult => ({ type: "literal", value: (match as RegExpExecArray)[0], }), }, ]; type FormatPart = { type: DateTimeFormatPartTypes; value: string | number; hour12?: boolean; }; type Format = FormatPart[]; export class DateTimeFormatter { #format: Format; constructor(formatString: string, rules: Rule[] = defaultRules) { const tokenizer = new Tokenizer(rules); this.#format = tokenizer.tokenize( formatString, ({ type, value, hour12 }) => { const result = { type, value, } as unknown as ReceiverResult; if (hour12) result.hour12 = hour12 as boolean; return result; }, ) as Format; } format(date: Date, options: Options = {}): string { let string = ""; const utc = options.timeZone === "UTC"; for (const token of this.#format) { const type = token.type; switch (type) { case "year": { const value = utc ? date.getUTCFullYear() : date.getFullYear(); switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2).slice(-2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "month": { const value = (utc ? date.getUTCMonth() : date.getMonth()) + 1; switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "day": { const value = utc ? date.getUTCDate() : date.getDate(); switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "hour": { let value = utc ? date.getUTCHours() : date.getHours(); if (token.hour12) { if (value === 0) value = 12; else if (value > 12) value -= 12; } switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "minute": { const value = utc ? date.getUTCMinutes() : date.getMinutes(); switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "second": { const value = utc ? date.getUTCSeconds() : date.getSeconds(); switch (token.value) { case "numeric": { string += value; break; } case "2-digit": { string += digits(value, 2); break; } default: throw Error( `FormatterError: value "${token.value}" is not supported`, ); } break; } case "fractionalSecond": { const value = utc ? date.getUTCMilliseconds() : date.getMilliseconds(); string += digits(value, Number(token.value)); break; } // FIXME(bartlomieju) case "timeZoneName": { // string += utc ? "Z" : token.value break; } case "dayPeriod": { string += token.value ? (date.getHours() >= 12 ? "PM" : "AM") : ""; break; } case "literal": { string += token.value; break; } default: throw Error(`FormatterError: { ${token.type} ${token.value} }`); } } return string; } parseToParts(string: string): DateTimeFormatPart[] { const parts: DateTimeFormatPart[] = []; for (const token of this.#format) { const type = token.type; let value = ""; switch (token.type) { case "year": { switch (token.value) { case "numeric": { value = /^\d{1,4}/.exec(string)?.[0] as string; break; } case "2-digit": { value = /^\d{1,2}/.exec(string)?.[0] as string; break; } } break; } case "month": { switch (token.value) { case "numeric": { value = /^\d{1,2}/.exec(string)?.[0] as string; break; } case "2-digit": { value = /^\d{2}/.exec(string)?.[0] as string; break; } case "narrow": { value = /^[a-zA-Z]+/.exec(string)?.[0] as string; break; } case "short": { value = /^[a-zA-Z]+/.exec(string)?.[0] as string; break; } case "long": { value = /^[a-zA-Z]+/.exec(string)?.[0] as string; break; } default: throw Error( `ParserError: value "${token.value}" is not supported`, ); } break; } case "day": { switch (token.value) { case "numeric": { value = /^\d{1,2}/.exec(string)?.[0] as string; break; } case "2-digit": { value = /^\d{2}/.exec(string)?.[0] as string; break; } default: throw Error( `ParserError: value "${token.value}" is not supported`, ); } break; } case "hour": { switch (token.value) { case "numeric": { value = /^\d{1,2}/.exec(string)?.[0] as string; if (token.hour12 && parseInt(value) > 12) { console.error( `Trying to parse hour greater than 12. Use 'H' instead of 'h'.`, ); } break; } case "2-digit": { value = /^\d{2}/.exec(string)?.[0] as string; if (token.hour12 && parseInt(value) > 12) { console.error( `Trying to parse hour greater than 12. Use 'HH' instead of 'hh'.`, ); } break; } default: throw Error( `ParserError: value "${token.value}" is not supported`, ); } break; } case "minute": { switch (token.value) { case "numeric": { value = /^\d{1,2}/.exec(string)?.[0] as string; break; } case "2-digit": { value = /^\d{2}/.exec(string)?.[0] as string; break; } default: throw Error( `ParserError: value "${token.value}" is not supported`, ); } break; } case "second": { switch (token.value) { case "numeric": { value = /^\d{1,2}/.exec(string)?.[0] as string; break; } case "2-digit": { value = /^\d{2}/.exec(string)?.[0] as string; break; } default: throw Error( `ParserError: value "${token.value}" is not supported`, ); } break; } case "fractionalSecond": { value = new RegExp(`^\\d{${token.value}}`).exec(string) ?.[0] as string; break; } case "timeZoneName": { value = token.value as string; break; } case "dayPeriod": { value = /^(A|P)M/.exec(string)?.[0] as string; break; } case "literal": { if (!string.startsWith(token.value as string)) { throw Error( `Literal "${token.value}" not found "${string.slice(0, 25)}"`, ); } value = token.value as string; break; } default: throw Error(`${token.type} ${token.value}`); } if (!value) { throw Error( `value not valid for token { ${type} ${value} } ${ string.slice( 0, 25, ) }`, ); } parts.push({ type, value }); string = string.slice(value.length); } if (string.length) { throw Error( `datetime string was not fully parsed! ${string.slice(0, 25)}`, ); } return parts; } /** sort & filter dateTimeFormatPart */ sortDateTimeFormatPart(parts: DateTimeFormatPart[]): DateTimeFormatPart[] { let result: DateTimeFormatPart[] = []; const typeArray = [ "year", "month", "day", "hour", "minute", "second", "fractionalSecond", ]; for (const type of typeArray) { const current = parts.findIndex((el) => el.type === type); if (current !== -1) { result = result.concat(parts.splice(current, 1)); } } result = result.concat(parts); return result; } partsToDate(parts: DateTimeFormatPart[]): Date { const date = new Date(); const utc = parts.find( (part) => part.type === "timeZoneName" && part.value === "UTC", ); const dayPart = parts.find((part) => part.type === "day"); utc ? date.setUTCHours(0, 0, 0, 0) : date.setHours(0, 0, 0, 0); for (const part of parts) { switch (part.type) { case "year": { const value = Number(part.value.padStart(4, "20")); utc ? date.setUTCFullYear(value) : date.setFullYear(value); break; } case "month": { const value = Number(part.value) - 1; if (dayPart) { utc ? date.setUTCMonth(value, Number(dayPart.value)) : date.setMonth(value, Number(dayPart.value)); } else { utc ? date.setUTCMonth(value) : date.setMonth(value); } break; } case "day": { const value = Number(part.value); utc ? date.setUTCDate(value) : date.setDate(value); break; } case "hour": { let value = Number(part.value); const dayPeriod = parts.find( (part: DateTimeFormatPart) => part.type === "dayPeriod", ); if (dayPeriod?.value === "PM") value += 12; utc ? date.setUTCHours(value) : date.setHours(value); break; } case "minute": { const value = Number(part.value); utc ? date.setUTCMinutes(value) : date.setMinutes(value); break; } case "second": { const value = Number(part.value); utc ? date.setUTCSeconds(value) : date.setSeconds(value); break; } case "fractionalSecond": { const value = Number(part.value); utc ? date.setUTCMilliseconds(value) : date.setMilliseconds(value); break; } } } return date; } parse(string: string): Date { const parts = this.parseToParts(string); const sortParts = this.sortDateTimeFormatPart(parts); return this.partsToDate(sortParts); } }