devex: migrate to vue-i18n (#4966)

* sample languages refactor

* feat: consistency + dedupe impl of i18n

* fix: broken imports

* fix: intl formatted component

* fix: use relative imports

* fix: imports

* fix: comment out incomplete locales + fix imports

* feat: cleanup

* fix: ui imports

* fix: lint

* fix: admonition import

* make footer a component, fix language reactivity

* make copyright notice untranslatable

---------

Co-authored-by: Calum H. <contact@cal.engineer>
This commit is contained in:
Prospector
2025-12-27 13:37:37 -08:00
committed by GitHub
parent 3cabc3b967
commit 1bbb01bd42
161 changed files with 1449 additions and 2314 deletions

View File

@@ -33,9 +33,9 @@
<script setup lang="ts">
import { AffiliateIcon, XCircleIcon } from '@modrinth/assets'
import type { AffiliateLink } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { AutoBrandIcon, ButtonStyled, CopyCode } from '../index.ts'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { AutoBrandIcon, ButtonStyled, CopyCode } from '../index'
withDefaults(
defineProps<{

View File

@@ -64,10 +64,10 @@
<script lang="ts"></script>
<script setup lang="ts">
import { AffiliateIcon, PlusIcon, SpinnerIcon, UserIcon, XIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, useTemplateRef } from 'vue'
import { AutoBrandIcon, Button, ButtonStyled, NewModal } from '../index.ts'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { AutoBrandIcon, Button, ButtonStyled, NewModal } from '../index'
export type CreateAffiliateProps = { sourceName: string; username?: string }
const props = withDefaults(

View File

@@ -100,7 +100,8 @@ import {
XIcon,
} from '@modrinth/assets'
import { capitalizeString } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
const messages = defineMessages({
acceptedLabel: {

View File

@@ -8,9 +8,10 @@
<script setup lang="ts">
import { CheckIcon, ClipboardCopyIcon } from '@modrinth/assets'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
import { defineMessage, useVIntl } from '../../composables/i18n'
const copiedMessage = defineMessage({
id: 'omorphia.component.copy.action.copy',
defaultMessage: 'Copy code to clipboard',

View File

@@ -49,7 +49,8 @@
</template>
<script setup lang="ts">
import { ClientIcon, GlobeIcon, InfoIcon, ServerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
const messages = defineMessages({
clientLabel: {

View File

@@ -17,9 +17,10 @@
<script setup lang="ts">
import { FilterIcon } from '@modrinth/assets'
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { watch } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
export type FilterBarOption = {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { EditIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { Avatar, OverflowMenu } from '../index'
const { formatMessage } = useVIntl()

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import IntlMessageFormat, { type FormatXMLElementFn, type PrimitiveType } from 'intl-messageformat'
import { computed, useSlots, type VNode } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MessageDescriptor } from '../../composables/i18n'
const props = defineProps<{
messageId: MessageDescriptor
values?: Record<string, PrimitiveType>
}>()
const slots = useSlots()
const { t, locale } = useI18n()
const formattedParts = computed(() => {
const key = props.messageId.id
const translation = t(key, {}) as string
let msg: string
if (translation && translation !== key) {
msg = translation
} else {
msg = props.messageId.defaultMessage ?? key
}
const slotHandlers: Record<string, FormatXMLElementFn<VNode>> = {}
const slotNames = Object.keys(slots)
for (const slotName of slotNames) {
const normalizedName = slotName.startsWith('~') ? slotName.slice(1) : slotName
slotHandlers[normalizedName] = (chunks) => {
const slot = slots[slotName]
if (slot) {
return slot({
children: chunks,
})
}
return chunks as VNode[]
}
msg = msg.replace(
new RegExp(`\\{${normalizedName}\\}`, 'g'),
`<${normalizedName}></${normalizedName}>`,
)
}
try {
const formatter = new IntlMessageFormat(msg, locale.value)
const result = formatter.format({
...props.values,
...slotHandlers,
})
if (Array.isArray(result)) {
return result
}
return [result]
} catch {
return [msg]
}
})
</script>
<template>
<template v-for="(part, index) in formattedParts" :key="index">
<component :is="() => part" v-if="typeof part === 'object'" />
<template v-else>{{ part }}</template>
</template>
</template>

View File

@@ -292,11 +292,11 @@ import {
XIcon,
YouTubeIcon,
} from '@modrinth/assets'
import NewModal from '@modrinth/ui/src/components/modal/NewModal.vue'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import NewModal from '../modal/NewModal.vue'
import Button from './Button.vue'
import Chips from './Chips.vue'
import FileInput from './FileInput.vue'

View File

@@ -31,7 +31,7 @@
</template>
<script setup lang="ts" generic="T">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
const modelValue = defineModel<T>({ required: true })

View File

@@ -34,9 +34,9 @@
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { renderString } from '@modrinth/utils'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { defineMessages, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import Admonition from './Admonition.vue'
import ButtonStyled from './ButtonStyled.vue'
import CopyCode from './CopyCode.vue'

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import type { MessageDescriptor } from '@vintl/vintl'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import type { MessageDescriptor } from '../../composables/i18n'
import { useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
const props = withDefaults(

View File

@@ -1,8 +1,8 @@
<script setup lang="ts" generic="T">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { type Component, computed } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
import ButtonStyled from './ButtonStyled.vue'

View File

@@ -29,6 +29,7 @@ export { default as FilterBar } from './FilterBar.vue'
export { default as HeadingLink } from './HeadingLink.vue'
export { default as HorizontalRule } from './HorizontalRule.vue'
export { default as IconSelect } from './IconSelect.vue'
export { default as IntlFormatted } from './IntlFormatted.vue'
export type { JoinedButtonAction } from './JoinedButtons.vue'
export { default as JoinedButtons } from './JoinedButtons.vue'
export { default as LoadingIndicator } from './LoadingIndicator.vue'

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { PlusIcon, XIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { nextTick, ref, useTemplateRef } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils'
import { ButtonStyled, NewModal } from '../index'
import type { AddPaymentMethodProps } from './AddPaymentMethod.vue'

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { formatPrice } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { useVIntl } from '../../composables/i18n'
import Accordion from '../base/Accordion.vue'
const { locale } = useVIntl()

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useVIntl } from '@vintl/vintl'
import type Stripe from 'stripe'
import { useVIntl } from '../../composables/i18n'
import { commonMessages, getPaymentMethodIcon, paymentMethodMessages } from '../../utils'
const { formatMessage } = useVIntl()

View File

@@ -2,10 +2,10 @@
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { formatPrice } from '@modrinth/utils'
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'

View File

@@ -8,10 +8,10 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import type Stripe from 'stripe'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useStripe } from '../../composables/stripe'
import { commonMessages } from '../../utils'
import { ButtonStyled } from '../index'

View File

@@ -542,11 +542,11 @@ import {
XIcon,
} from '@modrinth/assets'
import { calculateSavings, createStripeElements, formatPrice, getCurrency } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { Multiselect } from 'vue-multiselect'
import { defineMessages, useVIntl } from '../../composables/i18n'
import Admonition from '../base/Admonition.vue'
import Checkbox from '../base/Checkbox.vue'
import Slider from '../base/Slider.vue'

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { formatPrice } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, provide } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { InfoIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed, onMounted, ref, watch } from 'vue'
import { formatPrice } from '../../../../utils'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils.ts'
import { regionOverrides } from '../../utils/regions.ts'
import IntlFormatted from '../base/IntlFormatted.vue'
import Slider from '../base/Slider.vue'
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
import type { RegionPing, ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@vintl/vintl'
import type Stripe from 'stripe'
import { defineMessages, useVIntl } from '../../composables/i18n'
import PaymentMethodOption from './PaymentMethodOption.vue'
const { formatMessage } = useVIntl()

View File

@@ -11,11 +11,11 @@ import {
XIcon,
} from '@modrinth/assets'
import { formatPrice, getPingLevel } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import type Stripe from 'stripe'
import { computed } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import { regionOverrides } from '../../utils/regions'
import ButtonStyled from '../base/ButtonStyled.vue'

View File

@@ -2,11 +2,10 @@
import type { Archon } from '@modrinth/api-client'
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
import { getPingLevel } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { regionOverrides } from '../../utils/regions'
const { formatMessage } = useVIntl()
const currentRegion = defineModel<string | undefined>({ required: true })

View File

@@ -44,11 +44,11 @@
<script setup lang="ts">
import { renderHighlightedString } from '@modrinth/utils'
import type { VersionEntry } from '@modrinth/utils/changelog'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import { useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import AutoLink from '../base/AutoLink.vue'
const { formatMessage } = useVIntl()

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { type Component, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
export type Tab<Props> = {

View File

@@ -206,11 +206,11 @@ import {
type GameVersionTag,
type Version,
} from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { computed, type Ref, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRelativeTime } from '../../composables'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import AutoLink from '../base/AutoLink.vue'
import TagItem from '../base/TagItem.vue'

View File

@@ -87,10 +87,15 @@
import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrinth/assets'
import type { EnvironmentV3, GameVersionTag, PlatformTag, ProjectV3Partial } from '@modrinth/utils'
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
import { defineMessage, defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
defineMessage,
defineMessages,
type MessageDescriptor,
useVIntl,
} from '../../composables/i18n'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()

View File

@@ -48,9 +48,9 @@
</template>
<script setup lang="ts">
import { CrownIcon, ExternalIcon, OrganizationIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'

View File

@@ -65,11 +65,11 @@
</template>
<script setup lang="ts">
import { BookTextIcon, CalendarIcon, ExternalIcon, ScaleIcon, VersionIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useRelativeTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()

View File

@@ -102,7 +102,8 @@ import {
PayPalIcon,
WikiIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()

View File

@@ -4,9 +4,9 @@
<script setup lang="ts">
import type { ProjectStatus } from '@modrinth/utils'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { PROJECT_STATUS_ICONS } from '../../utils'
import Badge from '../base/SimpleBadge.vue'

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../../../composables/i18n'
import { commonProjectSettingsMessages } from '../../../../utils'
import LargeRadioButton from '../../../base/LargeRadioButton.vue'

View File

@@ -33,9 +33,9 @@
<script setup lang="ts">
import { BanIcon, LockIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed, type ComputedRef } from 'vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import TagItem from '../base/TagItem.vue'

View File

@@ -157,9 +157,9 @@ import {
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'

View File

@@ -13,10 +13,10 @@ import {
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T extends string">
import { MoonIcon, RadioButtonCheckedIcon, RadioButtonIcon, SunIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()
const { updateColorTheme, currentTheme, themeOptions, systemThemeColor } = defineProps<{

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { VersionChannel } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { defineMessages, useVIntl } from '../../composables/i18n'
const { formatMessage } = useVIntl()

View File

@@ -31,6 +31,7 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon } from '@modrinth/assets'
import type { Version, VersionFile } from '@modrinth/utils'
import { computed } from 'vue'
import { ButtonStyled, VersionChannelIndicator } from '../index'

View File

@@ -1,23 +1,58 @@
import { createFormatter, type FormatOptions, type Formatter } from '@vintl/how-ago'
import { useVIntl } from '@vintl/vintl'
import type { IntlController } from '@vintl/vintl/controller'
import { computed } from 'vue'
import { computed, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
/* eslint-disable @typescript-eslint/no-explicit-any */
const formatters = new WeakMap<IntlController<any>, Formatter>()
export type Formatter = (value: Date | number, options?: FormatOptions) => string
export interface FormatOptions {
roundingMode?: 'halfExpand' | 'floor' | 'ceil'
}
const formatters = new Map<string, ComputedRef<Intl.RelativeTimeFormat>>()
export function useRelativeTime(): Formatter {
const vintl = useVIntl()
const { locale } = useI18n()
let formatter = formatters.get(vintl)
const formatterRef = computed(
() =>
new Intl.RelativeTimeFormat(locale.value, {
numeric: 'auto',
style: 'long',
}),
)
if (formatter == null) {
const formatterRef = computed(() => createFormatter(vintl.intl))
const defaultOptions: FormatOptions = { roundingMode: 'halfExpand' as const }
formatter = (value, options) => formatterRef.value(value, { ...options, ...defaultOptions })
formatters.set(vintl, formatter)
if (!formatters.has(locale.value)) {
formatters.set(locale.value, formatterRef)
}
return formatter
return (value: Date | number) => {
const date = value instanceof Date ? value : new Date(value)
const now = Date.now()
const diff = date.getTime() - now
const seconds = Math.round(diff / 1000)
const minutes = Math.round(diff / 60000)
const hours = Math.round(diff / 3600000)
const days = Math.round(diff / 86400000)
const weeks = Math.round(diff / 604800000)
const months = Math.round(diff / 2629746000)
const years = Math.round(diff / 31556952000)
const rtf = formatterRef.value
if (Math.abs(seconds) < 60) {
return rtf.format(seconds, 'second')
} else if (Math.abs(minutes) < 60) {
return rtf.format(minutes, 'minute')
} else if (Math.abs(hours) < 24) {
return rtf.format(hours, 'hour')
} else if (Math.abs(days) < 7) {
return rtf.format(days, 'day')
} else if (Math.abs(weeks) < 4) {
return rtf.format(weeks, 'week')
} else if (Math.abs(months) < 12) {
return rtf.format(months, 'month')
} else {
return rtf.format(years, 'year')
}
}
}

View File

@@ -0,0 +1,195 @@
import IntlMessageFormat from 'intl-messageformat'
import type { Ref } from 'vue'
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
import { useI18n } from 'vue-i18n'
export interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor>
export type CrowdinMessages = Record<string, { message: string } | string>
export function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
export function defineMessages<K extends string, T extends MessageDescriptorMap<K>>(
descriptors: T,
): T {
return descriptors
}
export interface LocaleDefinition {
code: string
name: string
dir?: 'ltr' | 'rtl'
}
export const LOCALES: LocaleDefinition[] = [
// { code: 'af-ZA', name: 'Afrikaans' },
// { code: 'ar-EG', name: 'العربية (مصر)', dir: 'rtl' },
// { code: 'ar-SA', name: 'العربية (السعودية)', dir: 'rtl' },
// { code: 'az-AZ', name: 'Azərbaycan' },
// { code: 'be-BY', name: 'Беларуская' },
// { code: 'bg-BG', name: 'Български' },
// { code: 'bn-BD', name: 'বাংলা' },
// { code: 'ca-ES', name: 'Català' },
// { code: 'ceb-PH', name: 'Cebuano' },
// { code: 'cs-CZ', name: 'Čeština' },
// { code: 'da-DK', name: 'Dansk' },
{ code: 'de-CH', name: 'Deutsch (Schweiz)' },
{ code: 'de-DE', name: 'Deutsch' },
// { code: 'el-GR', name: 'Ελληνικά' },
// { code: 'en-PT', name: 'Pirate English' },
// { code: 'en-UD', name: 'Upside Down' },
{ code: 'en-US', name: 'English (United States)' },
// { code: 'eo-UY', name: 'Esperanto' },
{ code: 'es-419', name: 'Español (Latinoamérica)' },
{ code: 'es-ES', name: 'Español (España)' },
// { code: 'et-EE', name: 'Eesti' },
// { code: 'fa-IR', name: 'فارسی', dir: 'rtl' },
// { code: 'fi-FI', name: 'Suomi' },
// { code: 'fil-PH', name: 'Filipino' },
{ code: 'fr-FR', name: 'Français' },
// { code: 'he-IL', name: 'עברית', dir: 'rtl' },
// { code: 'hi-IN', name: 'हिन्दी' },
// { code: 'hr-HR', name: 'Hrvatski' },
// { code: 'hu-HU', name: 'Magyar' },
// { code: 'id-ID', name: 'Bahasa Indonesia' },
// { code: 'is-IS', name: 'Íslenska' },
{ code: 'it-IT', name: 'Italiano' },
// { code: 'ja-JP', name: '日本語' },
// { code: 'kk-KZ', name: 'Қазақша' },
// { code: 'ko-KR', name: '한국어' },
// { code: 'ky-KG', name: 'Кыргызча' },
// { code: 'lol-US', name: 'LOLCAT' },
// { code: 'lt-LT', name: 'Lietuvių' },
// { code: 'lv-LV', name: 'Latviešu' },
// { code: 'ms-Arab', name: 'بهاس ملايو (جاوي)', dir: 'rtl' },
{ code: 'ms-MY', name: 'Bahasa Melayu' },
// { code: 'nl-NL', name: 'Nederlands' },
// { code: 'no-NO', name: 'Norsk' },
{ code: 'pl-PL', name: 'Polski' },
{ code: 'pt-BR', name: 'Português (Brasil)' },
{ code: 'pt-PT', name: 'Português (Portugal)' },
// { code: 'ro-RO', name: 'Română' },
{ code: 'ru-RU', name: 'Русский' },
// { code: 'sk-SK', name: 'Slovenčina' },
// { code: 'sl-SI', name: 'Slovenščina' },
// { code: 'sr-CS', name: 'Српски (ћирилица)' },
// { code: 'sr-SP', name: 'Srpski (latinica)' },
// { code: 'sv-SE', name: 'Svenska' },
// { code: 'th-TH', name: 'ไทย' },
// { code: 'tl-PH', name: 'Tagalog' },
{ code: 'tr-TR', name: 'Türkçe' },
// { code: 'tt-RU', name: 'Татарча' },
{ code: 'uk-UA', name: 'Українська' },
// { code: 'vi-VN', name: 'Tiếng Việt' },
{ code: 'zh-CN', name: '简体中文' },
{ code: 'zh-TW', name: '繁體中文' },
]
export function transformCrowdinMessages(messages: CrowdinMessages): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string') {
result[key] = value
} else if (typeof value === 'object' && value !== null && 'message' in value) {
result[key] = value.message
}
}
return result
}
const LOCALE_CODES = new Set(LOCALES.map((l) => l.code))
/**
* Builds locale messages from glob-imported modules.
* Only includes locales that are defined in the LOCALES array.
* Usage: buildLocaleMessages(import.meta.glob('./locales/* /index.json', { eager: true }))
*/
export function buildLocaleMessages(
modules: Record<string, { default: CrowdinMessages }>,
): Record<string, Record<string, string>> {
const messages: Record<string, Record<string, string>> = {}
for (const [path, module] of Object.entries(modules)) {
// Extract locale code from path like './locales/en-US/index.json'
const match = path.match(/\/([^/]+)\/index\.json$/)
if (match) {
const locale = match[1]
// Only include locales that are in our LOCALES list
if (LOCALE_CODES.has(locale)) {
messages[locale] = transformCrowdinMessages(module.default)
}
}
}
return messages
}
/**
* Creates a vue-i18n message compiler that uses IntlMessageFormat for ICU syntax support.
* This enables pluralization, select, and other ICU message features.
*/
export function createMessageCompiler(): MessageCompiler {
return (msg, { locale, key, onError }) => {
let messageString: string
if (typeof msg === 'string') {
messageString = msg
} else if (typeof msg === 'object' && msg !== null && 'message' in msg) {
messageString = (msg as { message: string }).message
} else {
onError?.(new Error('Invalid message format') as CompileError)
return () => key
}
try {
const formatter = new IntlMessageFormat(messageString, locale)
return (ctx: MessageContext) => {
try {
return formatter.format(ctx.values as Record<string, unknown>) as string
} catch {
return messageString
}
}
} catch (e) {
onError?.(e as CompileError)
return () => key
}
}
}
export interface VIntlFormatters {
formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string
}
/**
* Composable that provides formatMessage() with the same API as @vintl/vintl.
* Uses vue-i18n's useI18n() under the hood.
*/
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
const { t, locale } = useI18n()
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string {
const key = descriptor.id
const translation = t(key, values ?? {})
if (translation && translation !== key) {
return translation as string
}
// Fallback to defaultMessage if key not found
const defaultMsg = descriptor.defaultMessage ?? key
try {
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
return formatter.format(values ?? {}) as string
} catch {
return defaultMsg
}
}
return { formatMessage, locale }
}

View File

@@ -1,4 +1,5 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './how-ago'
export * from './i18n'
export * from './scroll-indicator'

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@vintl/vintl'
import { defineMessages } from '../composables/i18n'
export const commonMessages = defineMessages({
affiliateLinksButton: {

View File

@@ -1,5 +1,6 @@
import { BlocksIcon, CompassIcon, EyeIcon, PickaxeIcon, UnknownIcon } from '@modrinth/assets'
import { defineMessage } from '@vintl/vintl'
import { defineMessage } from '../composables/i18n'
export const GAME_MODES = {
survival: {

View File

@@ -1,4 +1,4 @@
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
import { defineMessage, type MessageDescriptor } from '../composables/i18n'
export const NOTICE_LEVELS: Record<
string,

View File

@@ -1,4 +1,4 @@
import { defineMessage, type MessageDescriptor } from '@vintl/vintl'
import { defineMessage, type MessageDescriptor } from '../composables/i18n'
export const regionOverrides = {
'us-vin': {

View File

@@ -1,10 +1,11 @@
import type { Labrinth } from '@modrinth/api-client'
import { ClientIcon, ServerIcon } from '@modrinth/assets'
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { type Component, computed, readonly, type Ref, ref } from 'vue'
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
import { defineMessage, useVIntl } from '../composables/i18n'
type BaseOption = {
id: string
formatted_name?: string