* fix: files.vue bugs before styling changes * feat: move files tab to shared layout structure * fix: qa * fix: qa * fix: bugs * fix: lint * fix: admonition cleanup with progress + actions * fix: cleanup * fix: modals * fix: admon title * fix: i18n standard * fix: lint + i18n pass * fix: remove transition * fix: type errors * feat: files tab in app * fix: qa * fix: backup item minmax * fix: use ContentPageHeader for server panel * fix: lint * fix: lint * fix: lint * feat: page leave safety * fix: lint * fix: cargo fmt fix * fix: blank in prod * fix: content card table stuff * Revert "fix: blank in prod" This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace. * fix: import * feat: browse worlds/servers flow * fix: worlds tab parity with content tab * fix: perf bug + shader filter pill copy * feat: singleplayer filter * fix: ordering * fix: breadcrumbs * fix: lint * fix: qa * feat: store server proj id when adding to a non-linked instance * fix: lint * fix: i18n + qa * fix: conflict * qa: already installed modal + placeholders not server-specific * fix: qa * fix: add + edit server modals * fix: qa * fix: security * fix: devin flags * fix: lint * chore: change file to break build cache * fix: admon * fix: import path stuff * feat: qa * fix: fmt fmt idiot --------- Signed-off-by: Calum H. <calum@modrinth.com>
6.0 KiB
Internationalization (i18n)
All user-visible strings in Vue SFCs must use the localization system from @modrinth/ui. No hard-coded English strings should appear in templates or script — everything comes from formatMessage or <IntlFormatted>.
Translatable Strings
User-visible strings include: inner text, alt attributes, placeholder attributes, button labels, dropdown option labels, notification messages, etc.
Dynamic expressions ({{ user.name }}) and HTML tags are not translatable strings — only static human-readable text.
Message Definitions
Messages are defined with defineMessage or defineMessages from @modrinth/ui in <script setup>. Each message has a unique id and a defaultMessage containing the English string:
const messages = defineMessages({
welcomeTitle: { id: 'auth.welcome.title', defaultMessage: 'Welcome' },
welcomeDescription: { id: 'auth.welcome.description', defaultMessage: "You're now part of the community…" },
})
Message ids should be descriptive and stable (e.g. error.generic.default.title). Group related messages together with defineMessages.
Rendering Messages
Use useVIntl() from @modrinth/ui for simple string formatting:
const { formatMessage } = useVIntl()
<button>{{ formatMessage(messages.welcomeTitle) }}</button>
{{ formatMessage(messages.greeting, { name: user.name }) }}
ICU Message Format
Dynamic values use ICU placeholders in defaultMessage:
- Variables:
'Hello, {name}!' - Numbers/dates/times:
'{price, number, ::currency/USD}' - Plurals/selects:
'{count, plural, one {# message} other {# messages}}'
Writing Translation-Friendly Strings
ICU gives you powerful tools (plurals, selects, nested expressions), but translators in other languages face constraints that English doesn't have:
- Word order varies by language. Don't assume
{action} {noun}works everywhere — some languages need{noun} {action}or require prepositions between them. - Plurals aren't just "add an s". Many languages change internal parts of a word or phrase for pluralization, not just the ending. A simple
{count} {itemType}breaks ifitemTypeis always singular. - Grammatical gender affects surrounding words. Articles, adjectives, and verbs may change based on whether a noun is masculine or feminine. If a variable like
{contentType}can be "shader" or "mod", translators may need to inflect surrounding text differently for each.
Guidelines
- Use
selectfor content types, not bare variables. When a variable represents different content types (mod, shader, modpack, etc.), pass a key and use ICUselectso translators can write type-specific forms:
// Bad — translators can't inflect around a pre-rendered noun
'Delete {count} {itemType}'
// Good — translators can write entirely different phrases per type
'Delete {count} {contentType, select, mod {{count, plural, one {mod} other {mods}}} shader {{count, plural, one {shader} other {shaders}}} other {items}}'
This lets translators write entirely different noun forms per branch, which many languages require.
-
Prefer separate messages over complex ICU when branches diverge significantly. If the singular and plural versions of a string are structurally different (not just a noun change), use two separate message IDs rather than one complex ICU expression.
-
Don't concatenate translated strings. Never build a sentence by joining multiple
formatMessagecalls — the word order may be wrong in other languages. Put the entire sentence in one message. -
Keep variables semantic. Pass
contentType: 'mod'(a key), notcontentType: 'Mod'(a pre-rendered display string). Translators can then map each key to the correct form in their language. -
Test with long strings. German and Finnish words can be 2-3x longer than English equivalents. Ensure UI layouts don't break with longer text.
Rich-Text Messages
When a message contains links or markup, wrap the relevant ranges with named tags in defaultMessage:
"By creating an account, you agree to our <terms-link>Terms</terms-link> and <privacy-link>Privacy Policy</privacy-link>."
Render with the <IntlFormatted> component using named slots:
<IntlFormatted :message-id="messages.tosLabel">
<template #terms-link="{ children }">
<NuxtLink to="/terms">
<component :is="() => children" />
</NuxtLink>
</template>
<template #privacy-link="{ children }">
<NuxtLink to="/privacy">
<component :is="() => children" />
</NuxtLink>
</template>
</IntlFormatted>
For simple emphasis ('Welcome to <strong>Modrinth</strong>!'):
<template #strong="{ children }">
<strong><component :is="() => children" /></strong>
</template>
For complex child handling, use normalizeChildren from @modrinth/ui:
<template #bold="{ children }">
<strong><component :is="() => normalizeChildren(children)" /></strong>
</template>
Vue/ICU Delimiter Collisions
If an ICU placeholder ends right before }} in a Vue template, insert a space (} }) to avoid parsing issues.
Imports
All i18n utilities come from @modrinth/ui:
defineMessage/defineMessages— message definitionsuseVIntl— composable providingformatMessageIntlFormatted— component for rich-text messagesnormalizeChildren— helper for complex rich-text slot children
Reference Examples
- Variables and plurals:
apps/frontend/src/pages/frog.vue - Rich-text with link tags:
apps/frontend/src/pages/auth/welcome.vueandapps/frontend/src/error.vue