mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
* Reduce server memory usage and add container memory best practices Profiling a full-service boot showed ~300MB RSS per service process, much of it client-only dependencies pulled into the server module graph, and no heap limits applied in containers: - Import date-fns locales individually rather than via the locale index, which loaded all ~90 locales into every process - Load @sentry/react dynamically in insertFiles so the browser Sentry SDK stays out of the server-side editor graph - Replace class-validator isHexColor with the existing validateColorHex in the Highlight mark - Extract icon names into a standalone IconNames module so server-side schema validation no longer imports Font Awesome icon packs, with a test to keep it in sync with IconLibrary - Require S3Storage lazily so the AWS SDK and native CRT binding are only loaded when S3 file storage is configured - Default each forked service process to a V8 heap limit derived from the cgroup memory constraint, preventing several processes from together committing more memory than the container allows - Cap glibc malloc arenas in the Docker image to reduce resident memory fragmentation in multi-threaded processes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Require both file storage backends lazily for consistency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Load AWS SDK via dynamic imports compatible with test environment Requiring the storage TypeScript modules lazily broke under Vitest, which cannot resolve require() of source files and does not apply vi.mock to bare requires. Move the laziness into S3Storage instead: the module is imported statically, and the AWS SDK packages are loaded with dynamic imports on first use, including deferred creation of the S3 client. Also address review feedback: handle rejection of the dynamic Sentry import, and warn when the minimum per-process heap limit exceeds the memory budget of the container. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa * Consolidate S3 SDK loading into a single lazy accessor The commands are loaded together with the client in getS3() rather than as separate dynamic imports per method. The global S3Client test mock is now a class, as a mock function is not constructable when reached through a dynamic import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa --------- Co-authored-by: Claude <noreply@anthropic.com>
398 lines
10 KiB
TypeScript
398 lines
10 KiB
TypeScript
/* oxlint-disable import/no-duplicates */
|
|
import type { Locale } from "date-fns";
|
|
import {
|
|
addSeconds,
|
|
format,
|
|
formatDistanceToNow,
|
|
isSameYear,
|
|
isToday,
|
|
isTomorrow,
|
|
isYesterday,
|
|
parseISO,
|
|
subDays,
|
|
subMonths,
|
|
subWeeks,
|
|
subYears,
|
|
isValid,
|
|
parse,
|
|
} from "date-fns";
|
|
// Locales are imported from their individual modules rather than the
|
|
// "date-fns/locale" index, which would load every locale date-fns ships
|
|
// (~90) into memory rather than only those supported by the app.
|
|
import { ca } from "date-fns/locale/ca";
|
|
import { cs } from "date-fns/locale/cs";
|
|
import { de } from "date-fns/locale/de";
|
|
import { enGB } from "date-fns/locale/en-GB";
|
|
import { enUS } from "date-fns/locale/en-US";
|
|
import { es } from "date-fns/locale/es";
|
|
import { faIR } from "date-fns/locale/fa-IR";
|
|
import { fr } from "date-fns/locale/fr";
|
|
import { he } from "date-fns/locale/he";
|
|
import { hu } from "date-fns/locale/hu";
|
|
import { it } from "date-fns/locale/it";
|
|
import { ja } from "date-fns/locale/ja";
|
|
import { ko } from "date-fns/locale/ko";
|
|
import { nb } from "date-fns/locale/nb";
|
|
import { nl } from "date-fns/locale/nl";
|
|
import { ptBR } from "date-fns/locale/pt-BR";
|
|
import { pt } from "date-fns/locale/pt";
|
|
import { pl } from "date-fns/locale/pl";
|
|
import { sv } from "date-fns/locale/sv";
|
|
import { tr } from "date-fns/locale/tr";
|
|
import { vi } from "date-fns/locale/vi";
|
|
import { uk } from "date-fns/locale/uk";
|
|
import { zhCN } from "date-fns/locale/zh-CN";
|
|
import { zhTW } from "date-fns/locale/zh-TW";
|
|
import type { DateFilter } from "../types";
|
|
import { isBrowser } from "./browser";
|
|
|
|
/**
|
|
* Determines if the user's locale uses month-first date format (MM/dd).
|
|
*
|
|
* @returns true if locale uses MM/dd format, false for dd/MM format.
|
|
*/
|
|
export function usesMonthFirstFormat(): boolean {
|
|
if (!isBrowser || typeof Intl === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
// Format a known date and check if month comes before day
|
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}).format(new Date(2000, 11, 25)); // Dec 25, 2000
|
|
|
|
// If it starts with "12", month comes first
|
|
return formatted.startsWith("12");
|
|
}
|
|
|
|
/**
|
|
* Attempts to parse a date string in various common formats.
|
|
*
|
|
* @param dateStr The date string to parse.
|
|
* @returns a Date object if parsing is successful, null otherwise.
|
|
*/
|
|
export function parseDate(dateStr: string): Date | null {
|
|
if (!dateStr) {
|
|
return null;
|
|
}
|
|
|
|
// Remove any trailing alphabetic text (e.g., "Uhr", "at", "o'clock", etc.)
|
|
const cleaned = dateStr.trim().replace(/\s*[a-zA-Z]+\s*$/, "");
|
|
|
|
const monthFirst = [
|
|
"MM/dd/yyyy HH:mm:ss",
|
|
"MM/dd/yyyy HH:mm",
|
|
"MM/dd/yyyy",
|
|
"MM/dd HH:mm:ss",
|
|
"MM/dd HH:mm",
|
|
"MM/dd",
|
|
];
|
|
|
|
const dayFirst = [
|
|
"dd/MM/yyyy HH:mm:ss",
|
|
"dd/MM/yyyy HH:mm",
|
|
"dd/MM/yyyy",
|
|
"dd/MM HH:mm:ss",
|
|
"dd/MM HH:mm",
|
|
"dd/MM",
|
|
];
|
|
|
|
// Ambiguous slash formats - order based on user's locale
|
|
const slashFormats = usesMonthFirstFormat()
|
|
? [...monthFirst, ...dayFirst]
|
|
: [...dayFirst, ...monthFirst];
|
|
|
|
// Common date formats used in tables (with and without time, with and without year)
|
|
const formats = [
|
|
// ISO formats
|
|
"yyyy-MM-dd HH:mm:ss",
|
|
"yyyy-MM-dd HH:mm",
|
|
"yyyy-MM-dd",
|
|
// European dot formats
|
|
"dd.MM.yyyy HH:mm:ss",
|
|
"dd.MM.yyyy HH:mm",
|
|
"dd.MM.yyyy",
|
|
"dd.MM. HH:mm:ss",
|
|
"dd.MM. HH:mm",
|
|
"dd.MM.",
|
|
"d.M.yyyy HH:mm:ss",
|
|
"d.M.yyyy HH:mm",
|
|
"d.M.yyyy",
|
|
"d.M. HH:mm:ss",
|
|
"d.M. HH:mm",
|
|
"d.M.",
|
|
// Locale-dependent slash formats
|
|
...slashFormats,
|
|
];
|
|
|
|
const referenceDate = new Date();
|
|
|
|
for (const format of formats) {
|
|
const date = parse(cleaned, format, referenceDate);
|
|
if (isValid(date)) {
|
|
return date;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function subtractDate(date: Date, period: DateFilter) {
|
|
switch (period) {
|
|
case "day":
|
|
return subDays(date, 1);
|
|
|
|
case "week":
|
|
return subWeeks(date, 1);
|
|
|
|
case "month":
|
|
return subMonths(date, 1);
|
|
|
|
case "year":
|
|
return subYears(date, 1);
|
|
|
|
default:
|
|
return date;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a humanized relative time string for the given date.
|
|
*
|
|
* @param date The date to convert
|
|
* @param options The options to pass to date-fns
|
|
* @returns The relative time string
|
|
*/
|
|
export function dateToRelative(
|
|
date: Date | number,
|
|
options?: {
|
|
includeSeconds?: boolean;
|
|
addSuffix?: boolean;
|
|
locale?: Locale | undefined;
|
|
shorten?: boolean;
|
|
}
|
|
) {
|
|
const now = new Date();
|
|
const parsedDateTime = new Date(date);
|
|
|
|
// Protect against "in less than a minute" when users computer clock is off.
|
|
const normalizedDateTime =
|
|
parsedDateTime > now && parsedDateTime < addSeconds(now, 60)
|
|
? now
|
|
: parsedDateTime;
|
|
|
|
const output = formatDistanceToNow(normalizedDateTime, options);
|
|
|
|
// Some tweaks to make english language shorter.
|
|
if (options?.shorten) {
|
|
return output
|
|
.replace("about", "")
|
|
.replace("less than a minute ago", "just now")
|
|
.replace("minute", "min");
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from Unicode CLDR format to BCP47 format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeCLDRtoBCP47(locale: string) {
|
|
return locale.replace("_", "-").replace("root", "und");
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from BCP47 format to Unicode CLDR format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeBCP47toCLDR(locale: string) {
|
|
return locale.replace("-", "_").replace("und", "root");
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from Unicode CLDR format to ISO 639 format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeCLDRtoISO639(locale: string) {
|
|
return locale.split("_")[0];
|
|
}
|
|
|
|
/**
|
|
* Returns the current date as a string formatted depending on current locale.
|
|
*
|
|
* @returns The current date
|
|
*/
|
|
export function getCurrentDateAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleDateString(locale, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the current time as a string formatted depending on current locale.
|
|
*
|
|
* @returns The current time
|
|
*/
|
|
export function getCurrentTimeAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleTimeString(locale, {
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the current date and time as a string formatted depending on current
|
|
* locale.
|
|
*
|
|
* @returns The current date and time
|
|
*/
|
|
export function getCurrentDateTimeAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleString(locale, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
}
|
|
|
|
const locales = {
|
|
ca_ES: ca,
|
|
cs_CZ: cs,
|
|
de_DE: de,
|
|
en_GB: enGB,
|
|
en_US: enUS,
|
|
es_ES: es,
|
|
fa_IR: faIR,
|
|
fr_FR: fr,
|
|
he_IL: he,
|
|
hu_HU: hu,
|
|
it_IT: it,
|
|
ja_JP: ja,
|
|
ko_KR: ko,
|
|
nb_NO: nb,
|
|
nl_NL: nl,
|
|
pt_BR: ptBR,
|
|
pt_PT: pt,
|
|
pl_PL: pl,
|
|
sv_SE: sv,
|
|
tr_TR: tr,
|
|
uk_UA: uk,
|
|
vi_VN: vi,
|
|
zh_CN: zhCN,
|
|
zh_TW: zhTW,
|
|
};
|
|
|
|
/**
|
|
* Returns the date-fns locale object for the given user language preference.
|
|
*
|
|
* @param language The user language
|
|
* @returns The date-fns locale.
|
|
*/
|
|
export function dateLocale(language: keyof typeof locales | undefined | null) {
|
|
return language ? locales[language] : undefined;
|
|
}
|
|
|
|
export { locales };
|
|
|
|
/**
|
|
* Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local
|
|
* timezone. Used as the stored value for date mentions.
|
|
*
|
|
* @param date The date to format.
|
|
* @returns the date-only ISO string.
|
|
*/
|
|
export function toISODate(date: Date): string {
|
|
return format(date, "yyyy-MM-dd");
|
|
}
|
|
|
|
/**
|
|
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
|
|
* Strings carrying a time component are rejected so the date-only contract
|
|
* (and the day-granular comparisons that depend on it) cannot be violated.
|
|
*
|
|
* @param iso The date-only ISO string.
|
|
* @returns the parsed Date at local midnight, or null when the string is not a
|
|
* valid date-only value.
|
|
*/
|
|
export function parseISODate(iso: string): Date | null {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
|
return null;
|
|
}
|
|
const date = parseISO(iso);
|
|
return isValid(date) ? date : null;
|
|
}
|
|
|
|
/**
|
|
* Formats a date mention's stored ISO value into an absolute, localized,
|
|
* human-readable label. The year is omitted within the current year (e.g.
|
|
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). Suitable
|
|
* for plaintext and markdown serialization.
|
|
*
|
|
* @param iso The date-only ISO string.
|
|
* @param language The user's language preference.
|
|
* @returns the absolute human-readable date, or the original string when invalid.
|
|
*/
|
|
export function dateToReadable(
|
|
iso: string,
|
|
language?: keyof typeof locales | null
|
|
): string {
|
|
const date = parseISODate(iso);
|
|
if (!date) {
|
|
return iso;
|
|
}
|
|
const locale = dateLocale(language);
|
|
if (isSameYear(date, new Date())) {
|
|
return format(date, "MMMM do", { locale });
|
|
}
|
|
return format(date, "MMMM do, yyyy", { locale });
|
|
}
|
|
|
|
/**
|
|
* Formats a date mention's stored ISO value into a relative, localized,
|
|
* human-readable label with increasing granularity. Returns "Today",
|
|
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
|
|
* current year, and "February 3rd, 2024" otherwise.
|
|
*
|
|
* @param iso The date-only ISO string.
|
|
* @param t The translation function.
|
|
* @param language The user's language preference.
|
|
* @returns the relative human-readable date, or the original string when invalid.
|
|
*/
|
|
export function dateToRelativeReadable(
|
|
iso: string,
|
|
t: (key: string) => string,
|
|
language?: keyof typeof locales | null
|
|
): string {
|
|
const date = parseISODate(iso);
|
|
if (!date) {
|
|
return iso;
|
|
}
|
|
|
|
if (isToday(date)) {
|
|
return t("Today");
|
|
}
|
|
if (isTomorrow(date)) {
|
|
return t("Tomorrow");
|
|
}
|
|
if (isYesterday(date)) {
|
|
return t("Yesterday");
|
|
}
|
|
|
|
const locale = dateLocale(language);
|
|
if (isSameYear(date, new Date())) {
|
|
return format(date, "MMMM do", { locale });
|
|
}
|
|
return format(date, "MMMM do, yyyy", { locale });
|
|
}
|