2020-09-21 08:26:41 -04:00
|
|
|
// Copyright 2018-2020 the Deno authors. All rights reserved. MIT license.
|
2020-08-15 10:37:17 -04:00
|
|
|
import {
|
|
|
|
CallbackResult,
|
|
|
|
Rule,
|
|
|
|
TestFunction,
|
|
|
|
TestResult,
|
|
|
|
Tokenizer,
|
|
|
|
} from "./tokenizer.ts";
|
|
|
|
|
|
|
|
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://userguide.icu-project.org/formatparse/datetime)
|
|
|
|
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("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 };
|
|
|
|
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 }) => ({
|
|
|
|
type,
|
|
|
|
value,
|
|
|
|
})) as Format;
|
|
|
|
}
|
|
|
|
|
|
|
|
format(date: Date, options: Options = {}): string {
|
|
|
|
let string = "";
|
|
|
|
|
|
|
|
const utc = options.timeZone === "UTC";
|
|
|
|
const hour12 = this.#format.find(
|
|
|
|
(token: FormatPart) => token.type === "dayPeriod",
|
|
|
|
);
|
|
|
|
|
|
|
|
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();
|
|
|
|
value -= hour12 && date.getHours() > 12 ? 12 : 0;
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
case "timeZoneName": {
|
|
|
|
// string += utc ? "Z" : token.value
|
|
|
|
// break
|
|
|
|
}
|
|
|
|
case "dayPeriod": {
|
|
|
|
string += hour12 ? (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;
|
|
|
|
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 "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;
|
|
|
|
}
|
|
|
|
|
|
|
|
partsToDate(parts: DateTimeFormatPart[]): Date {
|
|
|
|
const date = new Date();
|
|
|
|
const utc = parts.find(
|
|
|
|
(part) => part.type === "timeZoneName" && part.value === "UTC",
|
|
|
|
);
|
|
|
|
|
|
|
|
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;
|
|
|
|
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);
|
|
|
|
return this.partsToDate(parts);
|
|
|
|
}
|
|
|
|
}
|