Skip to content

I18n ​

Usage ​

@saflib/vue depends on Vue I18n, but with a twist: strings are looked up by value, not key. So instead of:

html
<h1>{{ $t("hello.world") }}</h1>

You should use:

vue
<script setup lang="ts">
import { useReverseT } from "../../i18n.ts";
import { hello } from "./strings.ts";
const { t } = useReverseT();
</script>

<template>
  <h1>{{ t(hello.world) }}</h1>
</template>

See this i18n.ts section for more info on how to set that up.

Why? ​

Looking up strings by value provides a few key benefits:

  • In development, it's easier to find the value; you can use IDE features to jump to the string value quickly.
  • When creating the value-to-key mapping, duplicate strings are easy to find and warn about.
  • Most importantly, TypeScript highlights when pages are not using the correct key so you don't waste time checking if a string renders correctly.

Because behind the scenes the lookups are still using vue-i18n's t function, you still get all the vue-i18n features.

Interpolation (dynamic values in copy) ​

Messages use Vue I18n’s message syntax (ICU-style). Placeholders use a single pair of braces per name: {count}, {email}, {userName}.

Do not use Mustache-style double-brace placeholders inside strings that go through t(...) or <i18n-t>. For example, a message string must use {name} (single braces), not two brace pairs around name. Double braces in message strings are invalid for the message compiler and tend to fail in production builds (AOT compilation) while sometimes appearing to work in Vite dev mode.

Do not put a bare @ in messages (e.g. email placeholders like user@example.com). @ starts linked messages and often surfaces as SyntaxError: 10 (INVALID_LINKED_FORMAT) when the string is first rendered. Use literal interpolation instead: user{'@'}example.com.

How you inject values depends on where the copy is rendered:

WhereUse
Template body (paragraphs, alerts, dialog text, headings with dynamic bits)<i18n-t> with named slots
Script (computed strings, logic, concatenation)t(key, { … })
HTML attributes (:label, :title, :aria-label, :subtitle, …)t(key, { … }) — <i18n-t> cannot bind to attributes

Default to <i18n-t> in templates whenever the English string contains {placeholders}. Do not wrap t(...) in template mustache syntax in markup — that hides the interpolation pattern and cannot embed components (links, bold text, etc.):

html
<!-- anti-pattern: do not do this in real templates -->
<p>{{ t(strings.key, { name: value }) }}</p>

In templates: <i18n-t> ​

Vue I18n’s <i18n-t> component renders a message and replaces each {placeholder} with a named slot of the same name. With value-based strings you must pass the resolved vue-i18n key via lookupTKey.

Checklist:

  1. scope="global" — messages are registered on the global scope.
  2. :keypath="lookupTKey(strings.your_message)" — reverse-t stores strings by English value; lookupTKey turns that value into the key <i18n-t> expects.
  3. Named slot per placeholder — slot name must match the brace name in the string ({email} → #email).
  4. tag="…" — wrapper element (p, span, h1, …); pass classes on <i18n-t> itself.

Simple value — e.g. “Signed in as {email}”:

ts
// strings.ts
export const example = {
  email_line: "Signed in as {email}",
};
vue
<script setup lang="ts">
import { useReverseT } from "../../i18n.ts";
import { example as strings } from "./strings.ts";

const { lookupTKey } = useReverseT();
</script>

<template>
  <i18n-t
    scope="global"
    :keypath="lookupTKey(strings.email_line)"
    tag="p"
    class="text-body-1"
  >
    <template #email>{{ identityEmail }}</template>
  </i18n-t>
</template>

Multiple placeholders — e.g. “{count} files sent by {senders} are awaiting approval.”:

vue
<i18n-t
  scope="global"
  :keypath="lookupTKey(strings.pendingApprovalAlert)"
  tag="span"
>
  <template #count>{{ pendingApprovalCount }}</template>
  <template #senders>{{ pendingApprovalSendersLabel }}</template>
</i18n-t>

Embedded components — e.g. an inline link inside a sentence. Put the component in the slot; keep link label text in strings and translate it with t(...):

ts
// strings.ts
export const example = {
  tryAgainFrom: "Try again from {integrations}.",
  integrationsLink: "Integrations",
};
vue
<script setup lang="ts">
const { t, lookupTKey } = useReverseT();
</script>

<template>
  <v-alert type="error" variant="tonal">
    <i18n-t
      scope="global"
      :keypath="lookupTKey(strings.tryAgainFrom)"
      tag="span"
    >
      <template #integrations>
        <RouterLink :to="integrationsPath">
          {{ t(strings.integrationsLink) }}
        </RouterLink>
      </template>
    </i18n-t>
  </v-alert>
</template>

Real examples in the repo: VerifyWallIntro.vue, OAuthRedirectHandler.vue, and the workflow page template under saflib/vue/workflows/template/.

Anti-patterns — avoid these in templates:

vue
<!-- BAD: interpolation hidden in a t() call; no way to slot in a link -->
<p>{{ t(strings.connected_to, { account: accountLabel }) }}</p>

<!-- BAD: missing lookupTKey — keypath will not resolve with value-based strings -->
<i18n-t :keypath="strings.email_line">…</i18n-t>

<!-- BAD: slot name does not match placeholder name in the string -->
<i18n-t :keypath="lookupTKey(strings.email_line)">
  <template #userEmail>{{ identityEmail }}</template>
</i18n-t>

If a link or styled span does not appear after a hot reload, do a hard refresh — stale string maps from HMR can leave {placeholder} unbound.

In script and attributes: t(key, { … }) ​

Use the second argument to t when the result is consumed outside template body markup:

ts
// strings.ts
export const example = {
  itemsFound: "{count} items found",
  fieldLabel: "{propertyLabel}",
};

// Script — building a string for logic or a prop
const caption = computed(() =>
  t(example.itemsFound, { count: props.items.length }),
);

// Attribute — v-text-field :label, v-tooltip :text, aria-label, etc.
// :label="t(example.fieldLabel, { propertyLabel: edit.propertyLabel })"

For richer cases (pluralization, linked messages), see the Vue I18n message syntax docs.

Flat string objects (form field props) ​

The reverse-t function also handles flat objects, for seamless binding to HTML elements.

ts
// strings.ts
export const hello = {
  label: "hello label",
  placeholder: "hello placeholder",
  "aria-label": "hello aria label",
  role: "button",
  text: "hello text",
};
vue
<script setup lang="ts">
import { useReverseT } from "../../i18n.ts";
import { hello } from "./strings.ts";
const { t } = useReverseT();
</script>

<template>
  <!-- All strings are translated! -->
  <v-text-field v-bind="t(hello)" />
</template>

Strings objects can be any nesting of objects, arrays, and strings; the reverse-t function will properly map them to their correct Vue I18n keys.