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:
- Code and copy are separated: Reading
features.localeFiles.titlerequires jumping to the locale file to know what it actually says; - 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;
- One word with multiple meanings is hard to express: The Chinese word "删除" could be
DeleteorRemovein English, but a semantic key can only bind to one translation; - 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;
- 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:
- The source locale file is a mapping of "original text → original text";
- The target locale file uses the same set of keys, with translated values;
- All keys are flat strings, no nesting.
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:
- Code is copy, no need to jump between files when reading code;
- Source text is naturally the input to the LLM, no need to maintain an extra "key → source text" mapping;
- Flat JSON makes adding/deleting keys and diff comparisons straightforward;
- The same Chinese word can express different scenarios' translations via
__TRANS_ID.
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:
- The format of language JSON;
- 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:
- TypeScript / TSX;
<script>/<script setup>in Vue single-file components;- Vue template expressions and directive attributes, e.g.,
{{ t('保存') }},:title="t('删除', 'remove')".
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:
- Missing in source: used in code but not in
zh-CN.json; - Unused in source: exists in JSON but no static call or string literal found in code;
- Missing in target: key found in source code has no corresponding translation in target JSON;
- Extra in target: key exists in target JSON but no longer exists in source.
With parameters, it can auto-fix:
i18n-trans check --add --add-target --remove
--add: add the key to source, value is the source text itself;--add-target: add⟦TODO⟧ sourceplaceholder to target;--remove: delete unused / extra keys.
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:
- Source is missing
保存and删除keys; - Target is missing
保存,删除, and删除__TRANS_ID_remove(note: on the firstcheck, source still lacks the first two, so the target side only reports the variant; afterfixcompletes the source, the missing count becomes 3); - Target has an extra
过期文案.
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:
missingincludes the call locationsrc/App.tsx:2for direct navigation;删除__TRANS_ID_removeis a variant key derived fromt('删除', 'remove')in code;过期文案no longer exists in source, so it is classified asextra;- When there are missing keys, the exit code is
1, ready to be used as a gate in CI.
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:
--add: add two source keys with values equal to the source text;--add-target: add three⟦TODO⟧ sourceplaceholders to target;--remove: delete过期文案from target because it no longer exists in source.
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 key does not exist in target locale JSON;
- The value is the
⟦TODO⟧ sourceplaceholder; - The value equals the source text (to be compatible with old data).
The following are skipped:
- Already has a normal translation;
- Already has
⟦REVIEW⟧AI translation (to avoid re-translating); - Keys explicitly excluded in
translation.ignoreKeys.
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
}
key: the key written back to target JSON, possibly with a variant suffix;source: the source text actually given to the model for translation;transId: marker for distinguishing multiple meanings of one word;context: automatically read context around thet()call site;hint: manual hint for this key ini18n.config.json.
What the system prompt constrains
The system message mainly constrains:
- Translate only
source, notkey; - Return a JSON object in the format
{"results":[{"key":"...","value":"..."}]}; - Keys must exactly match input, cannot be added or removed;
- Preserve
{name}/{{name}}interpolation; the curly brace style must not change; - Preserve HTML tags, numbers, product names, code identifiers;
- Use
contextandhintto judge meaning; - Output only JSON, no markdown code fences or comments.
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:
- Key set exactly matches, no fewer or more keys;
- Each value is a non-empty string;
- 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
- Default
batchSize: 20, at most 20 units per request; - Executed serially, no concurrency;
contextLinescontrols how many lines around the call site are included in automatic context;- Configuration item
hintscan override / supplement context for a specific key; glossaryis used to unify terminology.
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:
- Deleting user data →
Delete - Removing a user from a role group →
Remove
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:
- Automatically read the
file:lineand nearby code at thet()call site; - Manually supplement explanations via
translation.hintsini18n.config.json.
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:
- Re-translate values that already have
⟦REVIEW⟧; - Overwrite existing translations;
- Lose placeholders or interpolation tokens (validates style and count of
{name}/{{name}}).
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:
- vue-i18n defaults to
{name}; - i18next defaults to
{{name}}.
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:
.envmust be added to.gitignore, never commit real secrets;- Process environment variables take precedence over
.env; you can also use--env-file <path>to specify another file; - Only
check/--dry-rundo not need.env; only real calls to the model require it; - You can keep a
.env.exampleas a template; the React/Vue playgrounds in the repository provide examples.
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:
- Source language is Chinese, target languages maintained by developers themselves;
- Projects with a certain amount of UI copy where missing/extra keys already affect maintenance;
- Want LLM to participate in translation but need human review as a fallback;
- Sharing the same translation workflow across frontend/backend, multiple frameworks, multiple repositories;
- Can accept the trade-off of "modifying source text = modifying key".
Not suitable for:
- Need strict separation between copy and code, translation teams need independent key catalogs;
- Complex ICU plural, gender, or selection rules (the current contract intentionally stays simple);
- Source text changes frequently and massively, and you do not want to trigger a large number of translation updates;
- Already have a mature multi-language platform and do not need LLM involvement.
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:
- Data format: source text as keys, flat JSON;
- Runtime contract:
t()'s lookup order and__TRANS_ID; - 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:
- GitHub: https://github.com/ALiuYiLin/i18n-llm-trans
- npm: https://www.npmjs.com/package/@10coding/i18n-trans
- Contract docs: https://github.com/ALiuYiLin/i18n-llm-trans/blob/master/docs/i18n-contract.md
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?