Impove Intl formatting (#5372)

* Improve Intl formatting

* Additional fixes

* Fixed formatters were not updated on locale change

* Fixed formatNumber was not updated on locale change

* Additional formatting and fixes after merge

* Run prepr:frontend

* Remove `'` in icon map

* Run `pnpm install`

* fix: lint + import

* Additional fixes

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Jerozgen
2026-03-10 00:29:32 +03:00
committed by GitHub
parent 9b2f0c88cd
commit f62c60a681
88 changed files with 839 additions and 621 deletions

View File

@@ -0,0 +1,38 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.DateTimeFormat>({ max: 40 })
export function useFormatDateTime(options?: Intl.DateTimeFormatOptions) {
const { locale } = injectI18n()
function format(date?: Date | number | string): string {
if (typeof date === 'number' || typeof date === 'string') {
date = new Date(date)
}
const formatter = getFormatter(locale.value, options)
return formatter!.format(date)
}
return format
}
function getFormatter(locale: string, options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
let cacheKey = locale
if (options) {
const entries = Object.entries(options)
.filter(([, value]) => value !== undefined)
.sort()
.map(([key, value]) => `${key}=${value}`)
cacheKey = [locale, ...entries].join(':')
}
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, options)
formatterCache.set(cacheKey, formatter)
}
return formatter
}

View File

@@ -0,0 +1,78 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 10 })
const maxDigitsCache = new LRUCache<string, number>({ max: 10 })
// `formatMoney(1234.56, 'USD')` → `$1,234.56`
export function useFormatMoney() {
const { locale } = injectI18n()
function format(number: number, currency = 'USD'): string {
try {
const formatter = getFormatter(locale.value, currency)
return formatter!.format(number)
} catch {
return `${currency} ${number.toFixed(2)}`
}
}
return format
}
// `formatPrice(123456, 'USD')` → `$1,234.56`
export function useFormatPrice() {
const { locale } = injectI18n()
function format(price: number, currency: string, trimZeros = false): string {
const maxDigits = getMaxDigits(currency)
const convertedPrice = price / Math.pow(10, maxDigits)
const minimumFractionDigits = trimZeros && Number.isInteger(convertedPrice) ? 0 : undefined
try {
const formatter = getFormatter(locale.value, currency, minimumFractionDigits)
return formatter.format(convertedPrice)
} catch {
return `${currency} ${convertedPrice}`
}
}
return format
}
function getFormatter(
locale: string,
currency: string,
minimumFractionDigits?: number,
): Intl.NumberFormat {
const cacheKey = `${locale}:${currency}:${minimumFractionDigits}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits,
})
formatterCache.set(cacheKey, formatter)
}
return formatter
}
function getMaxDigits(currency: string): number {
let maxDigits = maxDigitsCache.get(currency)
if (!maxDigits) {
try {
const formatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
})
maxDigits = formatter.resolvedOptions().maximumFractionDigits || 2
} catch {
maxDigits = 2
}
maxDigitsCache.set(currency, maxDigits)
}
return maxDigits
}

View File

@@ -0,0 +1,74 @@
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
const formatterCache = new LRUCache<string, Intl.NumberFormat>({ max: 15 })
// `formatNumber(1234567)` → `1,234,567`
export function useFormatNumber() {
const { locale } = injectI18n()
function format(value: number | bigint): string {
const formatter = getStandardFormatter(locale.value)
return formatter!.format(value)
}
return format
}
// `formatCompactNumber(1234567)` → `1.23M`
//
// Use `formatCompactNumberPlural` over `{(here!), plural, one {...} other {...}}`
export function useCompactNumber() {
const { locale } = injectI18n()
function formatCompactNumber(value: number | bigint): string {
if (value < 10_000) {
const standardFormatter = getStandardFormatter(locale.value)
return standardFormatter.format(value)
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
return twoDigitsCompactFormatter.format(value)
}
function formatCompactNumberPlural(value: number | bigint): string {
if (value < 10_000) {
return value.toString()
}
if (value < 1_000_000) {
const oneDigitCompactFormatter = getCompactFormatter(locale.value, 1)
return oneDigitCompactFormatter.format(value)
}
const twoDigitsCompactFormatter = getCompactFormatter(locale.value, 2)
return twoDigitsCompactFormatter.format(value)
}
return { formatCompactNumber, formatCompactNumberPlural }
}
function getStandardFormatter(locale: string): Intl.NumberFormat {
const cacheKey = `${locale}:standard`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale)
formatterCache.set(cacheKey, formatter)
}
return formatter
}
function getCompactFormatter(locale: string, maximumFractionDigits: number): Intl.NumberFormat {
const cacheKey = `${locale}:compact:${maximumFractionDigits}`
let formatter = formatterCache.get(cacheKey)
if (!formatter) {
formatter = new Intl.NumberFormat(locale, {
notation: 'compact',
maximumFractionDigits,
})
formatterCache.set(cacheKey, formatter)
}
return formatter
}

View File

@@ -1,35 +1,14 @@
import { computed, type ComputedRef } from 'vue'
import { LRUCache } from 'lru-cache'
import { injectI18n } from '../providers/i18n'
import { LOCALES } from './i18n.ts'
export type Formatter = (
value: Date | number | null | string | undefined,
options?: FormatOptions,
) => string
const formatterCache = new LRUCache<string, Intl.RelativeTimeFormat>({ max: 5 })
export interface FormatOptions {
roundingMode?: 'halfExpand' | 'floor' | 'ceil'
}
const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
export function useRelativeTime(): Formatter {
export function useRelativeTime() {
const { locale } = injectI18n()
const formatterRef = computed(() => {
const localeDefinition = LOCALES.find((loc) => loc.code === locale.value)
return new Intl.RelativeTimeFormat(locale.value, {
numeric: localeDefinition?.numeric || 'auto',
style: 'long',
})
})
if (!formatters.has(locale.value)) {
formatters.set(locale.value, formatterRef)
}
return (value: Date | number | null | string | undefined) => {
return (value: Date | number | string | null | undefined) => {
if (value == null) {
return ''
}
@@ -50,7 +29,7 @@ export function useRelativeTime(): Formatter {
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
const rtf = formatterRef.value
const rtf = getFormatter(locale.value)
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
@@ -69,3 +48,16 @@ export function useRelativeTime(): Formatter {
}
}
}
function getFormatter(locale: string): Intl.RelativeTimeFormat {
let formatter = formatterCache.get(locale)
if (!formatter) {
const localeDefinition = LOCALES.find((loc) => loc.code === locale)
formatter = new Intl.RelativeTimeFormat(locale, {
numeric: localeDefinition?.numeric || 'auto',
style: 'long',
})
formatterCache.set(locale, formatter)
}
return formatter
}

View File

@@ -1,5 +1,8 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './format-date-time'
export * from './format-money'
export * from './format-number'
export * from './how-ago'
export * from './i18n'
export * from './i18n-debug'