跪拜 Guibai
← Back to the summary

Easy-Lang Uses Chinese Strings as i18n Keys to Kill Variable Naming

Open source: https://github.com/chennlang/easy-lang · If you find it useful, welcome to ⭐ Star

I wonder if you have encountered the following problems in front-end internationalization development?

  1. Every time you translate a text, you have to think of a variable name. The naming process is repetitive and tedious.

  2. The translated source code becomes English variables, losing readability and searchability. It becomes impossible to locate the corresponding module by searching for the Chinese text on the interface.

  3. The translation process is very cumbersome: first name, then translate, then write to multiple translation files...

Common Front-end Internationalization Solutions

Let's take a login page as an example to see how traditional internationalization translation is used.

Before Translation

function LoginForm() {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const remainingAttempts = 3
  const lastLoginTime = new Date()

  return (
    <div className="login-container">
      <h1>用户登录</h1>
      {error && <div className="error">登录失败: {error.message}</div>}

      <form>
        <div className="form-group">
          <label htmlFor="username">用户名:</label>
          <input 
            id="username" 
            placeholder="请输入用户名或邮箱" 
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">密码:</label>
          <input
            id="password"
            type="password"
            placeholder="请输入6-20位密码"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
          <div className="hint">密码必须包含大小写字母和数字</div>
        </div>

        <button type="submit" disabled={!username || !password}>
          登录
        </button>

        <div className="footer">
          <span>您还可以尝试 {remainingAttempts} 次</span>
          <span>上次登录时间: {lastLoginTime.toLocaleString()}</span>
          <a href="/reset-password">忘记密码?</a>
        </div>
      </form>
    </div>
  )
}

After Translation

function LoginForm() {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const remainingAttempts = 3
  const lastLoginTime = new Date()

  return (
    <div className="login-container">
      <h1>{t('login.title')}</h1>
      {error && (
        <div className="error">
          {t('login.error.message', { error: error.message })}
        </div>
      )}

      <form>
        <div className="form-group">
          <label htmlFor="username">{t('login.username.label')}:</label>
          <input 
            id="username" 
            placeholder={t('login.username.placeholder')}
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">{t('login.password.label')}:</label>
          <input
            id="password"
            type="password"
            placeholder={t('login.password.placeholder')}
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
          <div className="hint">{t('login.password.hint')}</div>
        </div>

        <button 
          type="submit" 
          disabled={!username || !password}
          aria-label={t('login.submit.ariaLabel')}
        >
          {t('login.submit.text')}
        </button>

        <div className="footer">
          <span>
            {t('login.attempts.remaining', { count: remainingAttempts })}
          </span>
          <span>
            {t('login.lastLogin', { 
              datetime: lastLoginTime,
              formatParams: {
                datetime: { 
                  year: 'numeric', 
                  month: 'long', 
                  day: 'numeric',
                  hour: '2-digit',
                  minute: '2-digit'
                }
              }
            })}
          </span>
          <a href="/reset-password">
            {t('login.forgotPassword')}
          </a>
        </div>
      </form>
    </div>
  )
}

Translation Resource File zh-CN

{
  "login": {
    "title": "用户登录",
    "error": {
      "message": "登录失败: {error}"
    },
    "username": {
      "label": "用户名",
      "placeholder": "请输入用户名或邮箱"
    },
    "password": {
      "label": "密码",
      "placeholder": "请输入6-20位密码",
      "hint": "密码必须包含大小写字母和数字"
    },
    "submit": {
      "text": "登录",
      "ariaLabel": "提交登录表单"
    },
    "attempts": {
      "remaining": "您还可以尝试 {count} 次",
      "remaining_plural": "您还可以尝试 {count} 次"  // Some languages may require plural forms
    },
    "lastLogin": "上次登录时间: {datetime}",
    "forgotPassword": "忘记密码?"
  }
}

Translation Resource File en-US

{
  "login": {
    "title": "User Login",
    "error": {
      "message": "Login failed: {error}"
    },
    "username": {
      "label": "Username",
      "placeholder": "Enter username or email"
    },
    "password": {
      "label": "Password",
      "placeholder": "Enter password (6-20 characters)",
      "hint": "Password must contain uppercase, lowercase letters and numbers"
    },
    "submit": {
      "text": "Sign In",
      "ariaLabel": "Submit login form"
    },
    "attempts": {
      "remaining": "You have {count} attempt remaining",
      "remaining_plural": "You have {count} attempts remaining"
    },
    "lastLogin": "Last login: {datetime}",
    "forgotPassword": "Forgot password?"
  },
  "dateTimeFormats": {
    "short": {
      "year": "numeric",
      "month": "short",
      "day": "numeric",
      "hour": "2-digit",
      "minute": "2-digit"
    },
    "long": {
      "year": "numeric",
      "month": "long",
      "day": "numeric",
      "weekday": "long",
      "hour": "2-digit",
      "minute": "2-digit"
    }
  }
}

Translation Resource File zh-TW

{
   // ...omitted
}

Translation Resource File ja-JP

{
   // ...omitted
}

Translation Resource File ko-KR

{
   // ...omitted
}

More translation files...


Problems with Traditional i18n

1. Variable Naming

Every time I translate a Chinese string, I have to think of an English variable name. This is a bit painful for me, and it's also why I have always chosen tailwindcss. As you know, the hardest things for programmers are naming and caching.

2. Loss of Readability

The translated file is full of English variables. Sometimes the translation variable names are very arbitrary, making it extremely difficult to quickly locate an unfamiliar module.

3. Loss of Search Functionality

Then there's global search: usually, when we want to locate a certain text, we copy the text from the page and then globally search in the editor to find the target file/module. Now, you will only search to the translation file.

4. High Invasiveness

I believe that in business logic, internationalization code is inherently optional, just like the TS language, which can still run after removing all types. It should only be auxiliary coding and prompts. The current internationalization solutions restructure the code logic and replace the original character code.

5. Complex and Difficult Translation Process

Think about what our current translation process is like?

  1. Variable naming, rewriting code into t('string').

  2. Find a translation tool to translate t('string') into other languages.

  3. Find all translation files under locales, locate the corresponding module, and fill in the translated text.

The directory is as follows:

locales
  - en
    - translation.json
  - zh-CN
    - translation.json
  - zh-TW
    - translation.json
  ....

6. Lack of Reusability

The same translated text, such as 'Confirm', 'Cancel', 'Delete', these relatively common texts, are repeatedly defined in many translation module files. Of course, we can extract some common translations into a common module. However, doing so increases development complexity because before each translation, you have to first check whether this translation exists in the common module, and redefine it if not. This is very brain-consuming!

So, shouldn't development only focus on features and business? Why spend so much time on internationalization? After searching around, I didn't find an elegant solution, so I had to develop an internationalization translation tool myself!

Returning to the Essence

I understand that the principle of internationalization translation should be very simple, just like this:

const translations = {
    "退出登录": {
      "zh_CN": "退出登录",
      "zh_HK": "退出登錄",
      "en": "Logout"
    },
}

const currentLang = 'zh_CN'

function t(text: string) {
    return translations[text][currentLang]
}

console.log(t('退出登录'))

Easy Lang is Released

The problems with traditional internationalization solutions have always troubled me, sometimes even seriously affecting my daily development experience. After thinking for a long time, I decided to develop an internationalization tool in my mind. Here it comes~

Easy Lang is a low-intrusiveness, simple, multi-language translation tool. It is essentially framework-agnostic, a pure TS tool, so you can use it in projects that support native JS/TS, such as Vue, React, Angular, etc.

Feature Overview:

Project address: https://github.com/chennlang/easy-lang, if you find it useful, welcome to click ⭐ Star~

Installation

pnpm add easy-lang
# or
npm install easy-lang
# or
yarn add easy-lang

Quick Start

Suggested Directory Structure

locales/
  - index.ts
  - translation.json

1. Create Translation File

locales/translation.json

{
  "测试": {
    "zh-CN": "测试",
    "zh-TW": "測試",
    "en-US": "Test"
  },
  "测试{name}": {
    "zh-CN": "测试{name}",
    "zh-TW": "測試{name}",
    "en-US": "Test{name}"
  }
}

2. Usage

locales/index.ts

"use client";
import { createI18nTool } from "easy-lang";
import Transform from "./translation.json";

// Language list
export const langOptions = [
  {
    label: "English",
    value: "en-US",
  },
  {
    label: "简体中文",
    value: "zh-CN",
  },
  {
    label: "繁体中文",
    value: "zh-TW",
  },
] as const;

// Instance
export const i18nTool = createI18nTool<typeof Transform, (typeof langOptions)[number]["value"]>({
  defaultLang: 'zh-CN', // Default language
  langs: langOptions.map((lang) => lang.value), // Language list
  translations: Transform, // Translation file
})

// Translation method
export const $t = i18nTool.$t;

3. Using Translation

import { $t } from '@/locales/index'

// Normal translation
console.log($t('测试'))

// Translation with variables
console.log($t('测试{name}', { name: '你好' }))

4. Modular Translation

Suitable for large projects or scenarios where translations need to be organized by module.

Version >= v1.1.0

// Define modular translation files
const translations = {
  default: {
    '你好': {
      "zh-CN": "你好",
      "en-US": "Hello",
    },
  },
  custom: {
    '欢迎 {name}': {
      "zh-CN": "欢迎 {name}",
      "en-US": "Welcome {name}",
    },
    '测试': {
      "zh-CN": "测试",
      "en-US": "Test",
    },
  },
} as const;

// Create translation tool
const i18n = createI18nTool<typeof translations, "zh-CN" | "en-US">({
  defaultLang: "zh-CN",
  langs: ["zh-CN", "en-US"],
  translations,
});

// Method 1: Directly use translation with module
i18n.$t('欢迎 {name}', { name: '张三', module: 'custom' }); // => "欢迎 张三"
i18n.$t('测试', { module: 'custom' }); // => "测试"

// Method 2: Create a module-specific translation function (recommended)
const $t_custom = i18n.$module('custom');
$t_custom('测试'); // => "测试"
$t_custom('欢迎 {name}', { name: 'John' }); // => "欢迎 John"

Type-wise: $t("...") without module only allows keys from the default module; with { module: "xxx" } or using $module("xxx"), only keys from the corresponding module are allowed, enjoying full type hints.

What Does the Same Login Page Look Like Translated with Easy Lang?

Going back to the previous login page example, let's see how it is translated using Easy Lang:

function LoginForm() {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState(null)
  const remainingAttempts = 3
  const lastLoginTime = new Date()

  return (
    <div className="login-container">
      <h1>{$t('用户登录')}</h1>
      {error && (
        <div className="error">
          {$t('登录失败: {error}', { error: error.message })}
        </div>
      )}

      <form>
        <div className="form-group">
          <label htmlFor="username">{$t('用户名')}:</label>
          <input 
            id="username" 
            placeholder={$t('请输入用户名或邮箱')} 
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>

        <div className="form-group">
          <label htmlFor="password">{$t('密码')}:</label>
          <input
            id="password"
            type="password"
            placeholder={$t('请输入6-20位密码')}
            value={password}
            onChange={(e) => setPassword(e.target.value)}
          />
          <div className="hint">{$t('密码必须包含大小写字母和数字')}</div>
        </div>

        <button type="submit" disabled={!username || !password}>
          {$t('登录')}
        </button>

        <div className="footer">
          <span>
            {$t('您还可以尝试 {count} 次', { count: remainingAttempts })}
          </span>
          <span>
            {$t('上次登录时间: {datetime}', { datetime: lastLoginTime.toLocaleString() })}
          </span>
          <a href="/reset-password">{$t('忘记密码?')}</a>
        </div>
      </form>
    </div>
  )
}

The corresponding translation file, also just a single translation.json:

{
  "用户登录": {
    "zh-CN": "用户登录",
    "en-US": "User Login"
  },
  "登录失败: {error}": {
    "zh-CN": "登录失败: {error}",
    "en-US": "Login failed: {error}"
  },
  "用户名": {
    "zh-CN": "用户名",
    "en-US": "Username"
  },
  "请输入用户名或邮箱": {
    "zh-CN": "请输入用户名或邮箱",
    "en-US": "Enter username or email"
  },
  "密码": {
    "zh-CN": "密码",
    "en-US": "Password"
  },
  "请输入6-20位密码": {
    "zh-CN": "请输入6-20位密码",
    "en-US": "Enter password (6-20 characters)"
  },
  "密码必须包含大小写字母和数字": {
    "zh-CN": "密码必须包含大小写字母和数字",
    "en-US": "Password must contain uppercase, lowercase letters and numbers"
  },
  "登录": {
    "zh-CN": "登录",
    "en-US": "Sign In"
  },
  "您还可以尝试 {count} 次": {
    "zh-CN": "您还可以尝试 {count} 次",
    "en-US": "You have {count} attempt(s) remaining"
  },
  "上次登录时间: {datetime}": {
    "zh-CN": "上次登录时间: {datetime}",
    "en-US": "Last login: {datetime}"
  },
  "忘记密码?": {
    "zh-CN": "忘记密码?",
    "en-US": "Forgot password?"
  }
}

Compare the two approaches:

Comparison Item Traditional i18n Easy Lang
Code Writing t('login.title') $t('用户登录')
Variable Naming Need to think of English variable names first Not needed
Translation File One file per language, English key One translation.json, Chinese as key
Readability Full of English variables Chinese retained as is

Whatever Chinese you see in the code, that Chinese becomes the key in the translation file. The translated effect is WYSIWYG—no variable naming needed, no cross-file searching.

Using in React Projects

pnpm add @easy-lang/react
# or
npm install @easy-lang/react
# or
yarn add @easy-lang/react

React projects need to additionally install zustand as a peerDependency.

locales/index.ts

import translations from "./translation.json";
import { createI18nTool } from "easy-lang";
import { createReactI18nTool } from "@easy-lang/react";

const reactI18nTool = createReactI18nTool<
  typeof translations,
  "zh_CN" | "zh_HK" | "en"
>(
  createI18nTool({
    defaultLang: "zh_CN",
    langs: ["zh_CN", "zh_HK", "en"],
    translations,
  })
);

export const useTranslate = reactI18nTool.useTranslate();

App.tsx

import { useTranslate } from '@locales/index'
function App() {
  const { $t, changeLang, currentLang } = useTranslate;
  return (
    <div>
      <button onClick={() => changeLang("en")}>en</button>
      <button onClick={() => changeLang("zh_CN")}>中文</button>
      <div>当前语言: {currentLang}</div>
      <div>{$t("错误")}</div>
    </div>
  );
}

Note: After calling changeLang, it will execute location.reload() to refresh the page.

If your project only uses useTranslate for translation, please set autoReload: false, which allows reactive updates without refreshing the page.

Variable Replacement

Supports using {variableName} in translation text, e.g.:

{
  "欢迎 {name}": {
    "en": "Welcome, {name}!"
  }
}

Usage:

i18n.$t("欢迎 {name}", { name: "Tom" }); // => "Welcome, Tom!"

Forcing Translation Language

The third parameter of $t (and functions generated by $module) can temporarily override the current language for specific scenarios:

i18n.$t("保存", {}, "zh_HK"); // => "保存" (forced to Traditional Chinese)

configure() Runtime Configuration

Configuration can be dynamically adjusted at runtime without rebuilding the instance:

i18n.configure({
  defaultLang: "zh_CN",
  autoReload: false,        // Switch to reactive updates, no page refresh
  storageKey: "tenant-lang", // Custom storage key
});

Custom Language Storage

Defaults to using localStorage (key is lang). When the language comes from query parameters, host applications, cookie bridges, or existing settings centers, custom storage can be used:

const i18n = createI18nTool({
  defaultLang: "en",
  langs: ["zh_CN", "zh_HK", "en"],
  translations,
  storage: {
    getLang({ defaultLang, langs, storageKey }) {
      const stored = localStorage.getItem(storageKey);
      return stored && langs.includes(stored) ? stored : defaultLang;
    },
    setLang(lang, { storageKey }) {
      localStorage.setItem(storageKey, lang);
    },
  },
});

When getLang returns null or undefined, it falls back to defaultLang. In SSR scenarios (no window), it automatically degrades safely.

What Problems Does Easy Lang Solve?

easy-lang not only solves the problems of variable naming, high invasiveness, lack of searchability, low reusability, etc., but also brings new capabilities.

1. No More Variable Naming

First, it no longer requires you to manually name variables, nor does it change the original code structure. You just need to wrap all strings that need translation with $t() during development.

i18n.$t("你好");
i18n.$t("欢迎 {name}", { name: "Tom" }); 

Precisely because of this, the translated texts are all retained as-is in the code, preserving the code's readability and searchability. Currently, translation.json has only one level, solving the reusability problem, so it is advocated to reuse as much as possible.

2. Built-in TS Detection

Based on TS capabilities, easy-lang can detect untranslated texts and mark them in red, making it more convenient to check for untranslated texts.

3. Adapts to Modern AI Editors

Looking again at the structure of the translation file JSON, we find that all languages for a single translation are concentrated in one place, without needing to switch files.

{
  "测试": {
    "zh-CN": "测试",
    "zh-TW": "測試",
    "en-US": "Test"
  },
  "确认": {
    "zh-CN": "确认",
    "zh-TW": "確認",
    "en-US": "Confirm"
  }
}

If you are using Cursor or other Tab auto-completion tools, the translation process is even simpler.

Modifying translation: Change "描述" => "描述类型"

Adding translation:

4. As Simple a Translation Process as Possible

All untranslated texts are collected by easy-lang into the i18n.untranslatedList field. After module development is complete, print it out, translate it uniformly via AI, and write it back to the translation.json file.

Example:

console.log(i18n.untranslatedList) // ['暂无数据', '更新时间']

AI/Codex Skill

Copy to AI to automatically install the Codex skill from this repository:

Please install the Codex skill from the GitHub repository chennlang/easy-lang, path skills/easy-lang-app-i18n, and after installation use $easy-lang-app-i18n to help me integrate easy-lang internationalization into the application.
Please install the Codex skill from the GitHub repository chennlang/easy-lang, path skills/easy-lang-vscode-config, and after installation use $easy-lang-vscode-config to help me generate the configuration files required by the easy-lang-vscode plugin (.vscode/easy-lang.json, easyCode settings, locales/translation.json).

Easy Lang VSCode Plugin

Although the translation process has been simplified above, manual intervention is still needed for translation. To make the translation process even... more simple and efficient, I developed a VSCode plugin adapted for easy-lang. Just like the best swordsman is wasted without a dragon-slaying blade.

Installation

The plugin package is in the GitHub repository at packages/easy-lang-vscode/easy-lang-vscode-0.0.5.vsix.zip, or you can directly download easy-lang-vscode-0.0.5.vsix.zip.

Installation steps:

  1. Unzip the above file to get easy-lang-vscode-0.0.5.vsix
  2. Open VSCode, press Cmd+Shift+P (Windows: Ctrl+Shift+P) to open the command palette, execute Extensions: Install from VSIX...
  3. Select the unzipped .vsix file to complete the installation

Add .vscode/easy-lang.json

Configure the translation file directory and the languages to be translated:

{
  "translationPath": "locales/translation.json",
  "translateMode": "google",
  "targetLangs": ["en-US", "zh-CN", "zh-HK"],
  "model": {
    "endpoint": "",
    "model": "",
    "apiKey": ""
  }
}

You can also let AI automatically generate the configuration using the repository's built-in easy-lang-vscode-config skill.

Features

Sidebar

image.png

One-Click Translation

Click translate all to translate untranslated characters with one click and write them to the translation.json file.

image.png

image.png

Problems Encountered During Development

I knew that wanting to re-develop an i18n tool was definitely not as simple as imagined, so I decided to introduce it into my own project first, optimizing while using it. The following are the problems I encountered and how I solved them.

1. How to Achieve Reactive Updates When Switching Languages Without Refreshing the Page?

function useTranslate() {
    const lang = useLangStore()
    function $t() {
      //.......omitted
    }
    return { $t }
}

Actually, this is a false proposition. Even the currently popular i18next has not completely solved this: when used outside of a Component, you cannot use React's state or Vue's ref for reactive updates. For example, some pure JS/TS variables, functions, closures.

The most direct method currently is to refresh the page directly after switching languages. I think in actual usage scenarios, switching languages is not a frequent operation, and forcing a page refresh after switching languages is acceptable.

However, if you care a lot about user experience and cannot tolerate even an occasional page refresh, and want seamless language switching, then I suggest you use hooks. If you are using React, you can use hooks with @easy-lang/react. After switching languages, $t will update, triggering a page re-render. However, the way some constants are defined will need to change, for example:

// Normal definition
export const VARS = ['CONST1', 'CONST2']

// hooks definition
export const useVARS = () => {
    const { $t } = useTranslate()
    return [$t('CONST1'), $t('CONST2')]
}

2. Translation Functions Used in Non-React Components or Already Defined Methods (Closures) Do Not Update Reactively

This problem is similar to the one above, meaning that to achieve reactive updates under the premise of "not refreshing the page", there will be some trade-offs in the writing style, as follows:

const [pagination, setPagination] = useState<TablePaginationConfig>({
    current: 1,
    pageSize: 10,
    total: 0,
    showTotal: (total) => $t(`总共 {total} 条`, { total }),
});


useEffect(() => {
    setPagination({
      ...pagination,
      showTotal: (total) => $t(`总共 {total} 条`, { total }),
    });
}, [$t]);

The reason is that functions inside closures do not regenerate due to setState, and you need to listen to $t and reset it once.

3. One Word, Multiple Meanings

Because the same word can be used in two different places, and when translated into the target language, the result may differ depending on the context. For example:

{
    "模型管理": { // Normal translation
        "zh-CN": "模型管理",
        "en-US": "Model Management",
    },
    "模型管理": { // Translation for special scenarios like sidebar
        "zh-CN": "模型",
        "en-US": "Models"
    }
}

For example, 模型管理, the Chinese characters are the same. If placed in the left menu bar, because the width is limited and following industry standards, translating it to English as Models is more appropriate; but if it's just placed on a description page, then a direct translation to Model Management is fine.

So how to solve this problem?

{
    "模型管理": {
        "zh-CN": "模型管理",
        "en-US": "Model Management",
        "contexts": {
            "sidebar": {
                "zh-CN": "模型",
                "en-US": "Models"
            }
        }
    }
}

$t('模型管理', { context: 'sidebar' })

4. Translation by Module

For simple projects, one level is actually completely sufficient. However, for large projects, as the project grows day by day, without the concept of modules, modifying the same translation text may affect multiple places, which is unacceptable and would limit the user base, so modules are still necessary.

{
  "模型管理": {
    "zh-CN": "模型管理",
    "en-US": "Model Management",
    "contexts": {
      "sidebar": {
        "zh-CN": "模型",
        "en-US": "Models",
        "comment": "Sidebar menu term, length limited"
      }
    },
    "modules": {
      // billing module
      "billing": {
        "zh-CN": "账单模型",
        "en-US": "Billing Models",
        "contexts": {
          "sidebar": {
            "zh-CN": "账单",
            "en-US": "Bills"
          }
        }
      },
      // analytics module
      "analytics": {
        "zh-CN": "分析模型",
        "en-US": "Analytics Models"
      }
    }
  }
}

User Experience

The most noticeable feeling after switching to Easy Lang is that the efficiency of locating bugs has increased. Directly search for text, easily locate the component, then fix and commit. Compared to before, a lot of time spent on locating is saved.


Finally

If Easy-Lang is helpful to you, welcome to GitHub to click a ⭐ Star. Your support is my motivation for continuous iteration! Also welcome to submit Issues and PRs, let's make front-end internationalization simple together.

Easy-Lang is open-sourced under the MIT license, so you can use it in your projects with confidence.