dev-lang-natural
v0.1.0
Published
Checks whether translated UI strings sound natural to a native speaker, not just grammatically correct. Uses an LLM (BYOK) as a native-speaker judge at CI/build time.
Maintainers
Readme
dev-lang-natural
Check whether your translated UI strings sound natural to a native speaker — not just grammatically correct.
Existing i18n linters catch missing keys, unused keys, and broken placeholders. None of them tell you that a translation reads like a robot wrote it. That gap is the whole reason this package exists.
EN : "Shopping made easy" (a landing-page tagline)
ROBOTIC: "खरीदारी बनाया आसान" ← wrong verb form, robotic word order
NATURAL: "खरीदारी आसान बनाई गई" ← what a native actually writesOr the sneakier kind — grammatically perfect but wrong meaning:
EN : "Save changes" (a button)
WRONG : "Änderungen sparen" ← German "sparen" = save MONEY, not save data
NATURAL: "Änderungen speichern" ← the correct verbIt runs at CI / build time (not runtime), uses your own LLM key (BYOK — Gemini, OpenAI, or Anthropic), and caches results so unchanged strings never cost you twice.
How it works
You can't judge Hindi / Arabic / Spanish yourself, so the engine makes an LLM role-play as a native speaker judge. For every string it does two things:
- Back-translation — hides the English, asks the LLM to translate the target back to English, and compares meaning. Catches drift.
- Naturalness rating — the LLM acts as a monolingual native speaker, scores 0–10 how natural it reads, flags machine-translation smell, and suggests the natural version.
Before any of that, placeholders like {name} and {count} are verified deterministically — a dropped placeholder crashes your app and matters more than tone.
Install
npm install -D dev-lang-naturalConfigure
Create dev-lang-natural.config.js in your project root:
export default {
localesDir: "./messages",
sourceLocale: "en",
targetLocales: ["hi", "ar", "es"],
provider: "gemini", // gemini | openai | anthropic
threshold: 7, // fail below this naturalness score
context: {
"auth.submit": "button on login form",
"cart.checkout": "primary call-to-action button",
},
};The API key comes from the environment, never the config file:
export GEMINI_API_KEY=... # or OPENAI_API_KEY / ANTHROPIC_API_KEYRun
npx dev-lang-natural # check every target locale
npx dev-lang-natural --locale hi # check just one
npx dev-lang-natural --no-cache # re-judge everything
npx dev-lang-natural --json # machine-readable output for CIExample output
Every flagged string tells you which file, which key, the score, what's wrong, and the fix:
HI messages/hi.json ✗ 2/11 need attention
[3/10] tagline — messages/hi.json unnatural
source: Shopping made easy
current: खरीदारी बनाया आसान
why: wrong verb form; robotic word order
fix: खरीदारी आसान बनाई गई
[3/10] cart.itemCount — messages/hi.json broken placeholder
source: You have {count} items in your cart
current: आपकी टोकरी में सामान हैं
missing: {count} (placeholder dropped — will break the UI)
means: "There are items in your cart." (drifted from source)
fix: आपकी टोकरी में {count} आइटम हैं
11 checked · 9 natural · 2 flagged · 9 cached · 2 judgedExit codes (CI-friendly)
| Code | Meaning |
|------|---------|
| 0 | all translations read naturally |
| 1 | one or more flagged (fails the build) |
| 2 | usage / configuration error |
Programmatic API
The whole package is plumbing around one function, judge():
import { createProvider, judgeOne } from "dev-lang-natural";
const provider = createProvider("gemini");
const result = await judgeOne(provider, {
key: "tagline",
locale: "hi",
source: "Shopping made easy",
candidate: "खरीदारी बनाया आसान",
context: "marketing tagline on the landing page",
});
console.log(result.score); // 3
console.log(result.pass); // false
console.log(result.suggestion); // "खरीदारी आसान बनाई गई"Or run the full pipeline yourself:
import { loadConfig, run, renderReport } from "dev-lang-natural";
const config = await loadConfig();
const summary = await run(config);
console.log(renderReport(summary, config.threshold));
process.exit(summary.ok ? 0 : 1);Providers
| Provider | Env var | Default model |
|-------------|---------------------|----------------------------|
| gemini | GEMINI_API_KEY | gemini-2.0-flash |
| openai | OPENAI_API_KEY | gpt-4o-mini |
| anthropic | ANTHROPIC_API_KEY | claude-haiku-4-5 |
Override the model per project:
export default {
// ...
provider: "gemini",
providerOptions: { model: "gemini-2.5-flash", temperature: 0 },
};Adding a new provider is one small file implementing complete(system, user) — see src/providers/.
Caching
Verdicts are hashed on source + candidate + context + model and stored in .dev-lang-natural-cache.json. Each run only sends changed strings to the LLM, so CI stays cheap. Add the cache file to .gitignore (or commit it to share verdicts across the team — your call).
Scope for v1
- ✅ Naturalness scoring + native-speaker suggestions
- ✅ Back-translation meaning-drift detection
- ✅ Placeholder verification (
{name},{{name}},%s,:name) - ✅ Missing / extra key reporting
- ✅ Caching + pluggable providers + CI exit codes
- ⏭️
--fix(write suggestions back) — planned v2 - ⏭️ Dev-mode React overlay — planned v3
- ❌ RTL layout for Arabic — a separate concern, out of scope
License
MIT
