跪拜 Guibai
← Back to the summary

Using Chinese Source Text as i18n Keys: A CLI Workflow That Lets LLMs Handle the Translation Grind

An LLM-Based i18n Approach: Using Chinese Source Text as Keys for Easy Multi-Language Maintenance

When you do frontend i18n, how do you usually write copy?

One is the familiar semantic key approach:

t('features.localeFiles.title')
t('features.localeFiles.description')

The other is my current style - passing the Chinese source text directly to t():

t('语言资源文件')
t('翻译存放在 src/locales/*.json 中,键名就是中文原文。')

The first is semantic keys; the second uses Chinese source text directly as keys.

Neither is absolutely superior to the other. But in a scenario where the source language is Chinese, you continuously use LLMs for translation, and the frontend tech stack is not unified, the latter has solved many real maintenance problems for me.

This article discusses the runtime contract, CLI workflow, and some trade-offs I made in @10coding/i18n-trans.


Several pain points of traditional semantic keys

The typical structure of semantic keys looks like this:

{
  "features.localeFiles.title": "语言资源文件",
  "features.localeFiles.description": "翻译存放在 src/locales/*.json 中。"
}

It is not unusable, but maintenance cost rises as the project grows. A few very real pain points:

  1. Code and copy are separated: Reading features.localeFiles.title requires jumping to the locale file to know what it actually says;
  2. LLM translation lacks context: The model gets a key and a source sentence, but cannot tell whether this "delete" means deleting data or removing a user from a group;
  3. One word with multiple meanings is hard to express: The Chinese word "删除" could be Delete or Remove in English, but a semantic key can only bind to one translation;
  4. Missing keys and extra keys are hard to detect: Keys used in code but not in JSON, or keys in JSON that code no longer uses, all require manual comparison;
  5. Reuse across frameworks is difficult: React uses i18next, Vue uses vue-i18n, and key conventions and interpolation syntax differ.

Semantic keys suit large teams, parallel translation teams, and workflows where copy and code are strictly separated. But if your project is "Chinese copy is the product language, developers maintain translations themselves, and you want AI to participate," the burden becomes increasingly obvious.


Core idea: source text as keys

This approach's data format is very simple:

For example:

// src/locales/zh-CN.json
{
  "你好,{name}!": "你好,{name}!",
  "删除": "删除"
}
// src/locales/en-US.json
{
  "你好,{name}!": "Hello, {name}!",
  "删除": "Delete",
  "删除__TRANS_ID_remove": "Remove"
}

In code, you call it like this:

t('你好,{name}!', { name: 'Ada' })
t('删除')                    // Delete
t('删除', 'remove')          // Remove

It brings several direct benefits:

The cost is also clear: modifying the source text is modifying the key. Once the source text changes, the old translations in target languages no longer match. This trade-off will be discussed in detail later.


Runtime contract: no runtime package, only a CLI

This approach does not publish a React/Vue runtime package; it only ships a CLI.

The reason is simple: runtime environments vary too much. Vite, Webpack, SSR, micro-frontends, multiple instances, i18next versions, vue-i18n configs... A generic runtime is hard to cover all cases and can conflict with existing framework features.

So i18n-trans only constrains two things:

  1. The format of language JSON;
  2. The calling shape and lookup order of t().

t()'s signature is roughly:

type Translate = (
  source: string,
  options?: { __TRANS_ID?: string | number; [param: string]: unknown } | string | number,
) => string

Lookup order:

target locale <source>__TRANS_ID_<id>
  → target locale <source>
  → source locale <source>
  → source text itself

When current locale equals the source locale, directly return the source text.

Applications can continue using i18next, vue-i18n, or even write a small implementation themselves, as long as they follow this contract.


What the CLI actually solves

@10coding/i18n-trans has three core commands:

i18n-trans check    # check source / target locales
i18n-trans check --add --add-target --remove   # equivalent to fix
i18n-trans trans    # use LLM to translate pending items

1. Static scanning of t() calls

The CLI uses Babel to statically analyze calls to t() / translate() in code, supporting:

It does not depend on runtime, does not need to launch the app, and can be put into local scripts or CI.

2. Comparing language JSON

i18n-trans check

It reports four types of issues:

With parameters, it can auto-fix:

i18n-trans check --add --add-target --remove

It is fine if the target locale file does not exist or is empty; the CLI treats it as an empty locale and creates it during fix.

3. Calling LLM for translation

i18n-trans trans

It batches the keys to be translated and sends them to an OpenAI-compatible interface, defaulting to 20 per batch, executed serially.

Configuration is placed in the app directory's .env:

I18N_LLM_BASE_URL=https://api.deepseek.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=deepseek-chat

Services compatible with /chat/completions like OpenAI, DeepSeek, etc., can be directly connected.


Full demo: check → fix → check → trans

Below, I'll walk through the entire workflow with a small project that has only 3 keys.

src/App.tsx:

export const greeting = t('你好')
export const save = t('保存')
export const remove = t('删除', 'remove')

Initial locale files:

// src/locales/zh-CN.json
{
  "你好": "你好"
}
// src/locales/en-US.json
{
  "你好": "Hello",
  "过期文案": "Old"
}

I deliberately planted three issues here:

Step 1: check

$ i18n-trans check

Output:

i18n check · 1 files scanned · 3 static t() keys

zh-CN (source) · src/locales/zh-CN.json
  ✖ missing 2 key(s)
    - 保存  (src/App.tsx:2)
    - 删除  (src/App.tsx:3)

en-US (target) · src/locales/en-US.json
  ✖ missing 1 translation(s)
    - 删除__TRANS_ID_remove
  ⚠ extra 1 key(s) (not in the source locale)
    - 过期文案

✖ check failed: missing keys remain

Several details worth noting:

Step 2: fix

$ i18n-trans check --add --add-target --remove

Output:

zh-CN (source) · src/locales/zh-CN.json
  ✖ missing 2 key(s)
    - 保存  (src/App.tsx:2)
    - 删除  (src/App.tsx:3)

en-US (target) · src/locales/en-US.json
  ✖ missing 3 translation(s)
    - 保存
    - 删除
    - 删除__TRANS_ID_remove
  ⚠ extra 1 key(s) (not in the source locale)
    - 过期文案

+ zh-CN: added 2 key(s) in src/locales/zh-CN.json
+ en-US: added 3 key(s) in src/locales/en-US.json
- en-US: removed 1 key(s) in src/locales/en-US.json

✔ check passed

Note that it reports the check result first, then outputs the actual actions. Because --add first adds 保存 and 删除 to the source, the missing count in target changes from 1 to 3.

The file changes look like this:

// src/locales/zh-CN.json
 {
   "你好": "你好",
+  "保存": "保存",
+  "删除": "删除"
 }
// src/locales/en-US.json
 {
   "你好": "Hello",
-  "过期文案": "Old"
+  "保存": "⟦TODO⟧ 保存",
+  "删除": "⟦TODO⟧ 删除",
+  "删除__TRANS_ID_remove": "⟦TODO⟧ 删除"
 }

This step does three things:

Here, ⟦TODO⟧ means "the key exists but no translation yet" - it is not a translation result but a placeholder marker.

Run check again; both sides are now clean:

zh-CN (source) · src/locales/zh-CN.json   ✔ no issues
en-US (target) · src/locales/en-US.json   ✔ no issues
✔ check passed

But note: check passing does not mean translations exist in the target. The file still has ⟦TODO⟧ placeholders; the next step is translation.

Step 3: trans

First, dry-run to list pending items without calling the model:

$ i18n-trans trans --dry-run
i18n trans · zh-CN → en-US
dry run: 3 unit(s) to translate
[en-US] 3 unit(s)
  - 保存
  - 删除
  - 删除__TRANS_ID_remove

你好 already has translation Hello, so it is automatically skipped; the remaining three are placeholders.

Execute for real:

$ i18n-trans trans
i18n trans · zh-CN → en-US
[en-US] 3 unit(s)
[en-US]   ✔ 3 translated

✔ check passed
translated 3, failed 0, skipped 1

The target locale file becomes:

{
  "你好": "Hello",
  "保存": "⟦REVIEW⟧ Save",
  "删除": "⟦REVIEW⟧ Delete",
  "删除__TRANS_ID_remove": "⟦REVIEW⟧ Remove"
}

skipped 1 is the already-translated 你好. New translations default to having a ⟦REVIEW⟧ prefix for global search and manual review; use --ignore if you do not want the marker.

For reproducibility, this demo uses a local mock model returning fixed translations; the request structure is identical when actually connected to DeepSeek / OpenAI.


Inside trans: what a translation request actually sends

trans does not throw the whole locale file at the model. It first filters to construct translation units only for keys that truly need translation.

Full flow:

scan t() calls
  → derive expected keys (source keys + __TRANS_ID variants)
  → read target locale JSON
  → find untranslated items
  → construct unit (key / source / transId / context / hint)
  → batch by batchSize
  → POST /chat/completions
  → parse result
  → validate key, non-empty, placeholders
  → write back ⟦REVIEW⟧ translations
  → run check again

Which keys are sent for translation

An item is "untranslated" if any of the following holds:

The following are skipped:

Structure of a unit

Each pending item is constructed like this:

interface TranslationUnit {
  key: string       // "删除__TRANS_ID_remove"
  source: string   // "删除"
  transId?: string  // "remove"
  context?: string  // code snippet at call site
  hint?: string     // manually added semantic explanation
}

What the system prompt constrains

The system message mainly constrains:

The model should return:

{
  "results": [
    { "key": "保存", "value": "Save" },
    { "key": "删除", "value": "Delete" },
    { "key": "删除__TRANS_ID_remove", "value": "Remove" }
  ]
}

CLI also accepts a plain array or normal key/value map, but the results structure is recommended.

Response validation and retry

After receiving the response, the CLI performs three validations:

  1. Key set exactly matches, no fewer or more keys;
  2. Each value is a non-empty string;
  3. Placeholder tokens in source and translation are exactly the same, including the curly brace style and occurrence count of {name} / {{name}}.

Any failure triggers a retry up to maxRetries; if still failing, it logs the error and does not write bad data into the locale file.

Batching and context

This design is because giving the model a small batch with context is more stable than throwing the entire JSON at it, and failures are easier to locate and retry.


One word, many translations: __TRANS_ID

The word "删除" is typical:

In the semantic key approach, you typically need to create two keys. Now you can directly write:

t('删除', 'delete')
t('删除', { __TRANS_ID: 'remove' })

The target locale file corresponds to:

{
  "删除": "Delete",
  "删除__TRANS_ID_delete": "Delete",
  "删除__TRANS_ID_remove": "Remove"
}

The CLI scans the second argument or __TRANS_ID configuration in code and automatically derives variant keys.

More importantly, you can bring context along when translating:

For example:

{
  "translation": {
    "hints": {
      "删除__TRANS_ID_remove": "从角色组移出用户,对象仍然存在,不是销毁数据"
    }
  }
}

This way, when the model translates "删除", it will not always output Delete.


Using markers for manual review

The biggest fear with LLM translation is "it looks correct, but I dare not ship it directly."

This workflow uses three markers to distinguish states:

Marker Written by Meaning
⟦TODO⟧ source check --add-target Key exists but no translation yet
⟦REVIEW⟧ translation trans AI has translated, awaiting manual review
No marker Human / trans --ignore Translation confirmed

trans writes ⟦REVIEW⟧ by default rather than a clean translation. You can globally search ⟦REVIEW⟧ in your editor, review one by one, and remove the marker once confirmed.

It will not:

If some scenarios do not need review, use --ignore to write clean translations directly.


Interpolation: both single and double curly braces are recognized

Different frameworks have different interpolation syntax:

The CLI automatically detects which style the source text uses and requires the translation to preserve the same curly brace style:

{
  "你好,{name}!": "Hello, {name}!",
  "Hello, {{name}}!": "你好,{{name}}!"
}

If the model changes {{name}} to {name}, validation fails and triggers a retry, preventing interpolation breakage after deployment.

On the runtime side, corresponding configuration examples are provided:

// i18next: if you want interpolation style consistent with vue-i18n
i18n.init({
  fallbackLng: 'zh-CN',
  keySeparator: false,
  nsSeparator: false,
  interpolation: { prefix: '{', suffix: '}' },
})
// vue-i18n: keys are source text, may contain . or /, need messageResolver
const i18n = createI18n({
  legacy: false,
  messageResolver: (obj, path) => (obj as Record<string, unknown>)[path] ?? null,
})

Quick start

Install:

pnpm add -D @10coding/i18n-trans

Create i18n.config.json:

{
  "include": ["src/**/*.{ts,tsx,vue}"],
  "localesDir": "src/locales",
  "sourceLocale": "zh-CN",
  "targetLocales": ["en-US"],
  "callers": ["t", "translate"],
  "transId": {
    "option": "__TRANS_ID",
    "separator": "__TRANS_ID_"
  }
}

Prepare the source locale file:

// src/locales/zh-CN.json
{
  "你好,{name}!": "你好,{name}!",
  "删除": "删除"
}

Configure translation model (only needed for trans)

trans reads the .env in the project root (where i18n.config.json is located):

# .env
I18N_LLM_BASE_URL=https://api.deepseek.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=deepseek-chat

Three required variables:

Variable Description
I18N_LLM_BASE_URL OpenAI-compatible API URL, must support POST /chat/completions
I18N_LLM_API_KEY API key, sent as Authorization: Bearer <key>
I18N_LLM_MODEL Model name, e.g., deepseek-chat, gpt-4o-mini

Three optional variables:

Variable Default Description
I18N_LLM_TIMEOUT_MS 60000 Timeout per request (ms)
I18N_LLM_MAX_RETRIES 1 Retry count per batch after failure
I18N_LLM_JSON_MODE true Request response_format: { "type": "json_object" }; set false if provider does not support it

Few notes:

Run it:

# 1. Check missing keys
i18n-trans check

# 2. Add source keys + target placeholders
i18n-trans check --add --add-target

# 3. Translate after configuring .env
i18n-trans trans

# 4. Check again
i18n-trans check

If the target locale file does not exist, step 2 will create it and write ⟦TODO⟧ placeholders.


Who is it for, who is it not for

Suitable for:

Not suitable for:


Several design trade-offs

Why not use semantic keys

Semantic keys are not wrong; they just fit an organizational workflow where copy and translation are separated. For developers who maintain translations themselves and use LLM assistance, the source text itself is the best context; semantic keys add an extra layer of mapping cost.

Why no runtime package

Runtime environments differ too much. The CLI only relies on JSON format and t() call shape; applications follow the contract to implement t() and can continue using i18next, vue-i18n, or even write their own. The repository provides React and Vue reference implementations, but they serve as examples and are not published as npm packages.

Why retain manual review markers

LLM translation quality is unstable, and directly overwriting production copy is too risky. ⟦REVIEW⟧ turns "AI translation" and "human confirmation" into two explicit steps, gaining efficiency while controlling risk.


In closing

The core of this approach is not "another i18n library" but splitting i18n into three things:

  1. Data format: source text as keys, flat JSON;
  2. Runtime contract: t()'s lookup order and __TRANS_ID;
  3. Workflow: CLI handles scanning, filling, and translating; humans handle review.

Runtime is left to your own framework; translation is handled by LLM; quality is backed by markers and validation.

Project links:

If you are also maintaining a multi-language project, feel free to try this workflow or adapt it to your team. Comments welcome: how is your current i18n set up?