Регистрация пользовательских токенов

Система форматирования в Luxon основана на шаблонах токенов — специальных последовательностях символов, описывающих способ представления даты и времени. Стандартный набор токенов покрывает большинство задач: форматирование дней, месяцев, часов, временных зон, миллисекунд и других компонентов. Однако в крупных приложениях часто возникает необходимость создавать собственные соглашения форматирования и переиспользовать их как единый стандарт.

Под регистрацией пользовательских токенов обычно понимают создание слоя абстракции над стандартными форматами Luxon: пользовательские маски, словари токенов, фабрики форматирования и механизмы централизованного преобразования шаблонов.


Ограничения встроенной системы токенов

Luxon не предоставляет API наподобие:

DateTime.registerToken(...)

или:

Settings.addFormatToken(...)

Новые токены напрямую в движок Luxon добавить нельзя. Библиотека использует фиксированный набор шаблонов, совместимых с ICU-форматированием.

Например:

DateTime.now().toFormat("dd.MM.yyyy")

Поддерживаемые токены интерпретируются самим Luxon.

Попытка использовать неизвестный токен:

DateTime.now().toFormat("QQQ")

не создаёт новый форматтер — строка будет обработана как литерал или приведёт к неожиданному результату.

Поэтому пользовательские токены реализуются через:

  • промежуточное преобразование шаблонов;
  • словари форматов;
  • функции-компиляторы;
  • системы алиасов;
  • конфигурационные объекты;
  • расширяемые обёртки над DateTime.

Создание словаря пользовательских токенов

Наиболее распространённый подход — хранение собственных токенов в объекте-конфигурации.

Базовый пример

import { DateTime } from "luxon";

const customTokens = {
  SHORT_DATE: "dd.MM.yyyy",
  SHORT_TIME: "HH:mm",
  FULL_DATE: "dd LLLL yyyy",
  ISO_DATE: "yyyy-MM-dd"
};

function format(date, token) {
  return date.toFormat(customTokens[token]);
}

const now = DateTime.now();

console.log(format(now, "SHORT_DATE"));
console.log(format(now, "FULL_DATE"));

Такой подход создаёт единый слой стандартизации.


Преимущества пользовательских токенов

Централизованное управление форматами

Изменение формата производится в одном месте:

const customTokens = {
  SHORT_DATE: "yyyy/MM/dd"
};

Все вызовы автоматически используют новую маску.


Повторное использование

Без пользовательских токенов одинаковые строки форматирования дублируются:

date.toFormat("dd.MM.yyyy")

Во множестве файлов.

С пользовательскими токенами:

format(date, "SHORT_DATE")

Унификация интерфейсов

Особенно важно в:

  • дизайн-системах;
  • UI-компонентах;
  • корпоративных приложениях;
  • SSR-платформах;
  • системах отчётности.

Создание компилятора токенов

Более продвинутый вариант — преобразование пользовательских обозначений в стандартные Luxon-форматы.

Пример компилятора

import { DateTime } from "luxon";

const aliases = {
  YYYY: "yyyy",
  DD: "dd",
  MM: "LL",
  HH: "HH",
  mm: "mm"
};

function compileFormat(format) {
  let compiled = format;

  for (const token in aliases) {
    compiled = compiled.replaceAll(token, aliases[token]);
  }

  return compiled;
}

function formatDate(date, format) {
  return date.toFormat(compileFormat(format));
}

const now = DateTime.now();

console.log(formatDate(now, "DD.MM.YYYY"));
console.log(formatDate(now, "YYYY/MM/DD HH:mm"));

Поддержка Moment.js-совместимых токенов

При миграции с Moment.js часто возникает проблема несовместимости токенов.

Например:

Moment.js Luxon
YYYY yyyy
DD dd
dddd cccc
A a

Создание промежуточного слоя позволяет сохранить старые форматы.

Адаптер миграции

const momentToLuxon = {
  YYYY: "yyyy",
  YY: "yy",
  DD: "dd",
  D: "d",
  MM: "LL",
  MMMM: "LLLL",
  dddd: "cccc",
  HH: "HH",
  mm: "mm",
  ss: "ss"
};

function convertMomentFormat(format) {
  let result = format;

  Object.entries(momentToLuxon).forEach(([key, value]) => {
    result = result.replaceAll(key, value);
  });

  return result;
}

Проблема конфликтующих токенов

При замене токенов необходимо учитывать порядок обработки.

Ошибочный вариант

{
  YY: "yy",
  YYYY: "yyyy"
}

Если сначала заменить YY, формат:

YYYY-MM-DD

может превратиться в:

yyyyyy-MM-DD

Правильный подход

Сначала заменяются длинные токены.

const orderedTokens = [
  ["YYYY", "yyyy"],
  ["YY", "yy"]
];

Безопасный компилятор

function compile(format) {
  const tokens = [
    ["YYYY", "yyyy"],
    ["YY", "yy"],
    ["DD", "dd"],
    ["MM", "LL"]
  ];

  let result = format;

  for (const [from, to] of tokens) {
    result = result.replaceAll(from, to);
  }

  return result;
}

Регистрация токенов через классы

В крупных проектах форматирование обычно инкапсулируется.

Пример класса

import { DateTime } from "luxon";

class DateFormatter {
  constructor() {
    this.tokens = new Map();
  }

  register(name, format) {
    this.tokens.set(name, format);
  }

  format(date, token) {
    const format = this.tokens.get(token);

    if (!format) {
      throw new Error(`Unknown token: ${token}`);
    }

    return date.toFormat(format);
  }
}

const formatter = new DateFormatter();

formatter.register("API_DATE", "yyyy-MM-dd");
formatter.register("UI_DATE", "dd.MM.yyyy");

const now = DateTime.now();

console.log(formatter.format(now, "UI_DATE"));

Динамическая регистрация токенов

Токены могут загружаться:

  • с сервера;
  • из CMS;
  • из базы данных;
  • из конфигурационных файлов;
  • из пользовательских настроек.

Пример JSON-конфигурации

{
  "DATE_SHORT": "dd.MM.yyyy",
  "DATE_LONG": "dd LLLL yyyy",
  "TIME_SHORT": "HH:mm"
}

Подключение конфигурации

import config from "./formats.json";

class Formatter {
  constructor(tokens) {
    this.tokens = tokens;
  }

  format(date, token) {
    return date.toFormat(this.tokens[token]);
  }
}

const formatter = new Formatter(config);

Локализация пользовательских токенов

Один и тот же пользовательский токен может иметь разные форматы в зависимости от локали.

Пример

const localeFormats = {
  ru: {
    SHORT: "dd.MM.yyyy"
  },

  en: {
    SHORT: "MM/dd/yyyy"
  }
};

Форматирование по локали

function format(date, token, locale) {
  const formatString = localeFormats[locale][token];

  return date
    .setLocale(locale)
    .toFormat(formatString);
}

Токены для временных зон

Пользовательские токены особенно полезны при работе с часовыми поясами.

Конфигурация

const formats = {
  LOG_TIME: "yyyy-MM-dd HH:mm:ss ZZZZ",
  UTC_TIME: "yyyy-MM-dd'T'HH:mm:ss'Z'"
};

Использование

const now = DateTime.now().setZone("UTC");

console.log(now.toFormat(formats.LOG_TIME));

Пользовательские пресеты

Luxon предоставляет встроенные пресеты:

DateTime.DATE_SHORT
DateTime.DATE_MED
DateTime.DATE_FULL

Но часто требуется собственный набор.

Создание пресетов

const presets = {
  REPORT: {
    year: "numeric",
    month: "long",
    day: "2-digit"
  },

  COMPACT: {
    year: "2-digit",
    month: "2-digit",
    day: "2-digit"
  }
};

Использование preset-объектов

function formatPreset(date, preset) {
  return date.toLocaleString(presets[preset]);
}

Комбинирование пользовательских токенов

Токены могут состоять из других токенов.

Пример композиции

const tokens = {
  DATE: "dd.MM.yyyy",
  TIME: "HH:mm:ss",
  DATETIME: "{DATE} {TIME}"
};

Компилятор вложенных токенов

function resolve(format, dictionary) {
  return format.replace(/\{(.*?)\}/g, (_, token) => {
    return dictionary[token];
  });
}

Использование

const result = resolve(tokens.DATETIME, tokens);

console.log(result);

Получится:

dd.MM.yyyy HH:mm:ss

Рекурсивное разрешение токенов

Для сложных систем требуется рекурсивная обработка.

Реализация

function resolveRecursive(format, dictionary) {
  return format.replace(/\{(.*?)\}/g, (_, token) => {
    return resolveRecursive(dictionary[token], dictionary);
  });
}

Кэширование пользовательских токенов

Постоянная компиляция форматов может создавать лишние накладные расходы.

Пример кэша

class TokenCompiler {
  constructor() {
    this.cache = new Map();
  }

  compile(format) {
    if (this.cache.has(format)) {
      return this.cache.get(format);
    }

    const compiled = format
      .replaceAll("YYYY", "yyyy")
      .replaceAll("DD", "dd");

    this.cache.set(format, compiled);

    return compiled;
  }
}

Валидация пользовательских токенов

Ошибочные токены могут приводить к некорректному выводу.

Проверка существования

function validateToken(name, dictionary) {
  if (!dictionary[name]) {
    throw new Error(`Token "${name}" is not registered`);
  }
}

Проверка циклических зависимостей

При рекурсивных токенах возможны циклы.

Ошибочный пример

const tokens = {
  A: "{B}",
  B: "{A}"
};

Защита от рекурсии

function resolve(format, dictionary, visited = new Set()) {
  return format.replace(/\{(.*?)\}/g, (_, token) => {

    if (visited.has(token)) {
      throw new Error("Circular token reference");
    }

    visited.add(token);

    return resolve(dictionary[token], dictionary, visited);
  });
}

Интеграция с TypeScript

TypeScript позволяет типизировать пользовательские токены.

Использование union-типов

type FormatToken =
  | "SHORT_DATE"
  | "LONG_DATE"
  | "TIME";

function format(
  date: DateTime,
  token: FormatToken
) {
  return date.toFormat(formats[token]);
}

Enum-подход

enum Tokens {
  SHORT = "SHORT",
  LONG = "LONG"
}

Типизированный словарь

const formats: Record<Tokens, string> = {
  [Tokens.SHORT]: "dd.MM.yyyy",
  [Tokens.LONG]: "dd LLLL yyyy"
};

Расширение DateTime через обёртки

Хотя напрямую расширять DateTime не рекомендуется, можно создавать собственные адаптеры.

Пример

class AppDate {
  constructor(date) {
    this.date = date;
  }

  format(token) {
    return this.date.toFormat(customTokens[token]);
  }
}

Использование Proxy для регистрации токенов

Современный JavaScript позволяет создавать динамические API.

Пример Proxy

const formats = {
  short: "dd.MM.yyyy",
  time: "HH:mm"
};

const formatter = new Proxy(formats, {
  get(target, prop) {
    return DateTime.now().toFormat(target[prop]);
  }
});

console.log(formatter.short);

Пользовательские токены в SSR-приложениях

В серверном рендеринге важно:

  • хранить токены централизованно;
  • избегать глобального состояния;
  • учитывать локали запроса;
  • учитывать timezone пользователя.

Пример фабрики

function createFormatter(locale) {
  return {
    format(date, token) {
      return date
        .setLocale(locale)
        .toFormat(tokens[token]);
    }
  };
}

Использование токенов в React

Создание форматтера

export const formats = {
  CARD_DATE: "dd.MM.yyyy",
  CARD_TIME: "HH:mm"
};

Использование в компоненте

function DateLabel({ value }) {
  return (
    <span>
      {value.toFormat(formats.CARD_DATE)}
    </span>
  );
}

Использование токенов в Node.js

Форматы логирования

const logFormats = {
  FILE: "yyyy-MM-dd HH:mm:ss",
  CONSOLE: "HH:mm:ss"
};

Логгер

function log(message) {
  const timestamp = DateTime.now()
    .toFormat(logFormats.FILE);

  console.log(`[${timestamp}] ${message}`);
}

Организация структуры токенов

В больших проектах токены обычно разделяются по доменам.

Пример структуры

formats/
  ui.js
  api.js
  reports.js
  logs.js

UI-токены

export const uiFormats = {
  SHORT_DATE: "dd.MM.yyyy",
  TIME: "HH:mm"
};

API-токены

export const apiFormats = {
  ISO: "yyyy-MM-dd'T'HH:mm:ss"
};

Лучшие практики

Использование семантических имён

Плохо:

DATE1
FORMAT2

Хорошо:

USER_PROFILE_DATE
INVOICE_TIMESTAMP
LOG_ENTRY_TIME

Исключение дублирования

Нежелательно:

"dd.MM.yyyy"

во множестве мест проекта.


Разделение UI и API-форматов

UI-форматы ориентированы на читаемость:

dd.MM.yyyy

API-форматы — на стандартизацию:

yyyy-MM-dd'T'HH:mm:ss

Использование ISO там, где возможно

Luxon предоставляет готовые методы:

toISO()
toISODate()
toISOTime()

Они надёжнее пользовательских шаблонов для обмена данными.


Типичные ошибки

Смешивание Moment.js и Luxon токенов

Ошибка:

DateTime.now().toFormat("YYYY-MM-DD")

Правильно:

DateTime.now().toFormat("yyyy-LL-dd")

Использование нестабильных сокращений

Некоторые сокращённые обозначения могут вести себя неоднозначно в разных локалях.


Отсутствие единого реестра

Хаотичные строки форматирования усложняют поддержку приложения.


Жёстко закодированные локали

Нежелательно:

date.setLocale("ru")

внутри универсальных утилит.

Локаль должна передаваться извне.


Архитектура централизованной регистрации токенов

Полноценный реестр

class FormatRegistry {
  constructor() {
    this.formats = new Map();
  }

  register(name, pattern) {
    this.formats.set(name, pattern);
  }

  get(name) {
    return this.formats.get(name);
  }

  format(date, token) {
    const pattern = this.get(token);

    if (!pattern) {
      throw new Error(`Unknown token: ${token}`);
    }

    return date.toFormat(pattern);
  }
}

Инициализация

const registry = new FormatRegistry();

registry.register(
  "USER_DATE",
  "dd.MM.yyyy"
);

registry.register(
  "ADMIN_TIMESTAMP",
  "yyyy-MM-dd HH:mm:ss"
);

Использование

const result = registry.format(
  DateTime.now(),
  "ADMIN_TIMESTAMP"
);

console.log(result);