> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/development/i18n.md).

# i18n

MyIP ships in four languages, and all four are first-class. There is no "main" locale that gets features first.

| Code | Language           | File                       |
| ---- | ------------------ | -------------------------- |
| `en` | English (fallback) | `frontend/locales/en.json` |
| `zh` | Simplified Chinese | `frontend/locales/zh.json` |
| `fr` | French             | `frontend/locales/fr.json` |
| `ru` | Russian            | `frontend/locales/ru.json` |

## Setup

The app uses **vue-i18n** in Composition API mode. The instance is created in `frontend/locales/i18n.js` with `legacy: false` and `fallbackLocale: 'en'`, then registered in `main.js`.

In a component you pull `t()` off `useI18n()`:

```vue
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

<template>
  <p>{{ t('macchecker.Note') }}</p>
</template>
```

Nothing user-visible is hardcoded. Every string goes through `t()`.

## Locale files

Each locale file is one JSON object, namespaced by feature. Top-level namespaces include `nav`, `advancedtools`, `page`, `changelog`, and one per tool — `macchecker`, `whois`, `dnsresolver`, `censorshipcheck`, and so on.

A tool's namespace normally matches its registry slug:

{% code title="frontend/locales/en.json" %}

```json
"macchecker": {
  "Title": "MAC Lookup",
  "Note": "Query the manufacturer of a physical address (MAC address)…",
  "Note2": "Please enter a physical address to start the query:",
  "Placeholder": "F0:2F:4B:01:0A:AA",
  "invalidMAC": "Invalid physical address",
  "fetchError": "Unable to fetch query results"
}
```

{% endcode %}

The card description on the homepage lives separately, in the shared `advancedtools` namespace, because that is what the tools registry points `noteKey` at:

```json
"advancedtools": {
  "MacChecker": "Query information of a physical address"
}
```

**Key names are identical across all four files. Only the values differ.**

## Loading is on demand

All four packs are never loaded together. Bundling them eagerly cost roughly 44 KB gzipped of dead weight — three unused locales on every page load.

Instead `frontend/locales/i18n.js` holds a map of dynamic imports:

{% code title="frontend/locales/i18n.js" %}

```js
const localeLoaders = {
  en: () => import('./en.json'),
  zh: () => import('./zh.json'),
  fr: () => import('./fr.json'),
  ru: () => import('./ru.json'),
};
```

{% endcode %}

The i18n instance starts with **empty** messages. `loadActiveLocaleMessages()` injects the active locale plus the `en` fallback (in parallel, memoized), and `main.js` awaits it before mounting — so the first render is already translated. Switching language persists the choice and re-boots the app, which means exactly one locale is ever active per page load.

### How the language is picked

`setLanguage()` in `frontend/locales/i18n.js` resolves, in order:

1. **Stored preference** in `localStorage` — the current preferences key, then the legacy keys, so a freshly bumped key still finds an older choice.
2. **`?hl=` query parameter**, if it names a supported locale.
3. **Browser language** (`navigator.language`), matched on its first two characters.
4. **`en`.**

A build-time Vite plugin (`localePreloadPlugin` in `vite.config.js`) replicates that exact order in a tiny inline `<head>` script and emits a `<link rel="modulepreload">` for the chosen pack while the HTML is still streaming — so the locale downloads in parallel with the main bundle instead of after it. A wrong guess only wastes one preload; the real import still decides.

After messages load, `updateMeta()` sets `document.documentElement.lang` (with `zh` declared as `zh-CN`, since the pack is Simplified-only) and refreshes the `title`, `keywords`, and `description` meta tags from the `page.*` keys.

## Sub-packs

Two datasets are large enough that they stay out of the main locale pack and load on demand for the active locale only:

<table><thead><tr><th width="300">Sub-pack</th><th>Loaded by</th></tr></thead><tbody><tr><td><code>frontend/locales/security-checklist/{en,zh,fr,ru}.json</code></td><td><code>SecurityChecklist.vue</code> — its own loader map; the dataset is roughly 30 KB gzipped per language and only that one tool reads it.</td></tr><tr><td><code>frontend/locales/privacy/{en,zh,fr,ru}.json</code></td><td><code>PrivacyPolicy.vue</code> — merged into i18n via <code>mergeLocaleMessage()</code> so <code>t()</code> and <code>tm()</code> resolve the copy normally.</td></tr></tbody></table>

Both follow the same pattern: a `{ en, zh, fr, ru }` map of dynamic imports, falling back to `en` for an unknown locale.

{% hint style="info" %}
**When to add a sub-pack:** a dataset that is large, belongs to exactly one lazily-loaded view, and would otherwise sit in every visitor's first-paint bundle. Ordinary UI strings always go in the main pack.
{% endhint %}

## The four-locale rule

{% hint style="warning" %}
**Any change that surfaces copy lands in all four locales in the same change.** Not a follow-up PR, not a TODO.
{% endhint %}

That means:

* A new key goes into `en.json`, `zh.json`, `fr.json`, **and** `ru.json`.
* A reworded string is reworded in all four.
* A deleted key is deleted from all four.
* The accompanying `changelog.json` entry carries all four translations.

`fallbackLocale: 'en'` means a missing key degrades to English rather than showing a raw key path — which is exactly why partial coverage is easy to miss in review. Don't rely on it.

If you genuinely cannot produce a translation, say so in the PR. A maintainer would rather fix wording than discover a missing locale after release.

## The changelog

Release notes live in `frontend/data/changelog.json`, not in the locale files. The file is an array of version blocks, **oldest first** — the About panel renders it reversed, so new entries are appended to the last block.

{% code title="frontend/data/changelog.json" %}

```json
{
  "version": "v7.2.0",
  "date": "Beta",
  "content": [
    {
      "type": "add",
      "change": {
        "en": "Rebuilt the Censorship Check: see where a website is blocked worldwide",
        "zh": "全面重构封锁测试，可以查询一个网站在全球的封锁情况",
        "fr": "Refonte complète du test de censure : découvrez où un site est bloqué dans le monde",
        "ru": "Полностью переработана проверка цензуры: видно, где в мире сайт заблокирован"
      }
    }
  ]
}
```

{% endcode %}

Shape rules:

| Field     | Rule                                                          |
| --------- | ------------------------------------------------------------- |
| `version` | String matching `vX.Y…`                                       |
| `date`    | String — a release date, or a placeholder like `"Beta"`       |
| `content` | Non-empty array of change items                               |
| `type`    | Exactly one of `add`, `improve`, `fix`                        |
| `change`  | Object with **all four** locale keys, each a non-empty string |

### Enforced by tests

`tests/changelog.test.js` runs on every `pnpm test` and fails the build on:

* a missing or empty translation for any of `en` / `zh` / `fr` / `ru`
* a `type` outside the allowed three
* a version block missing `version`, `date`, or a non-empty `content` array
* a locale file that reintroduces `changelog.versions` (that data now lives only in `changelog.json`)
* a locale file that has lost the `changelog.Title` / `add` / `improve` / `fix` UI labels

That last pair is the split worth remembering: **release note text lives in `changelog.json`; the badge labels and panel title around it stay in the locale files** as ordinary UI chrome.

## Adding a language

Nothing in the codebase forbids a fifth locale, but it is a real commitment — every future copy change would then need five translations, and `tests/changelog.test.js` hardcodes the required four. Open an issue and discuss it with the maintainers before starting.

## Next

* [Adding a New Tool](/developer/development/adding-a-new-tool.md) — where the i18n step fits in a full feature.
* [Testing](/developer/development/testing.md) — what else `pnpm test` enforces.
* [How to Contribute](/developer/contributing/how-to-contribute.md) — PR expectations.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/development/i18n.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
