Files
modrinth/standards/frontend/INTERNATIONALIZATION.md
ef4044534f feat: update auth flow (#5790)
* Backend routes for choosing username in OAuth flow

* fix up oauth flow routes

* improve URL-related OAuth code

* Use user-provided callback addr instead of SELF_ADDR

* Revert "Use user-provided callback addr instead of SELF_ADDR"

This reverts commit 7ea0635d86.

* fix flow

* fix: backend response for create oauth account

* feat: new auth flow (#5840)

* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue

* refactor: pnpm prepr

* fix: make sure staging uses staging

* fix: hcaptcha styles

* fix: copy

* remove: auth/welcome page as its no longer used

* refactor: bring root page card styles into individual components and use tailwind

* fix: account settings modals to use new modal and fix lots of bad styles

* refactor: pnpm prepr

* feat: implement last signed in indicator

* fix: append number when generated name from email is taken

* refactor: pnpm prepr

* fix: last sign in badge color

* fix: qa issues

* refactor: pnpm prepr

* fix: hover effect on native date picker

* chore: temp staging undo

* Revert "chore: temp staging undo"

This reverts commit cad6bd4f92.

* feat: handle app create account

* fix: last signed in style

* fix: add initOnMounted for SSR race

* refactor: use typescript

* refactor: pnpm prepr

* refactor: use typescript for reset-password

* refactor: convert verify-email to use typescript

* refactor: convert authorize.vue to use typescript

* fix: authorize.vue error states

* feat: small style updates

* feat: implement date picker component

* feat: improve UX and styles for range select

* refactor: pnpm prepr

* fix: range select border styles

* feat: implement date picker component in create account

* feat: implement preserve date for date picker

* update rust toolchain

* increase recursion limit

* fix: date picker can be null

* fix: calculate age based on user's timezone

* fix: number input icons color

* fix: date picker icons

* feat: improve styles

* fix: add width on date

* fix: hover color bad on number input

* fix lints

* feat: add default date open view

* fmt

* fix: account.vue

* fix: remove default date to open 13 years ago

* fix: edit copy on info banner

* fix: cannot hover over project card tooltip items (#6071)

fix: cannot hover over project cards

* feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search

* fix: open modrinth project links in the app (#6072)

* pin tanstack versions + set pnpm min age to 7 days

* squash commits

* fix: 2 factor auth enter code screen styles

* update copy

* update copy

* improve reset password

* feat: update sign in screen

* fix: unused import

* Merge branch 'main' into boris/dev-908-backend-changes

* Revert "Merge branch 'main' into boris/dev-908-backend-changes"

This reverts commit b9b03796e3.

* fix: add stroke

* feat: add passkey support (#6375)

* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json

* fmt

* feat: improve manage passkeys UI

* fix copy

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com>
2026-06-25 20:36:19 +00:00

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 if itemType is 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

  1. Use select for content types, not bare variables. When a variable represents different content types (mod, shader, modpack, etc.), pass a key and use ICU select so 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.

  1. 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.

  2. Don't concatenate translated strings. Never build a sentence by joining multiple formatMessage calls — the word order may be wrong in other languages. Put the entire sentence in one message.

  3. Keep variables semantic. Pass contentType: 'mod' (a key), not contentType: 'Mod' (a pre-rendered display string). Translators can then map each key to the correct form in their language.

  4. 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 definitions
  • useVIntl — composable providing formatMessage
  • IntlFormatted — component for rich-text messages
  • normalizeChildren — 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/error.vue