i18n: restore code-based language syntax

This commit is contained in:
m1ngsama 2026-05-24 11:06:29 +08:00
parent a693d281f8
commit 8eb311e54b
7 changed files with 117 additions and 43 deletions

1
.gitignore vendored
View file

@ -15,6 +15,7 @@ tests/unit/test_history_view
tests/unit/test_i18n
tests/unit/test_system_message
tests/unit/test_help_text
tests/unit/test_manual_text
tests/unit/test_support_text
tests/unit/test_cli_text
tests/unit/test_ratelimit

View file

@ -10,6 +10,13 @@
instead of the removed support entry.
- The concise manual module is now named `manual_text`, and the redundant
interactive `:commands` entrypoint was removed.
- The concise `:help` manual now stays within one command-output screen so it
does not truncate on normal terminal sizes.
- Language selection is limited to stable codes (`en`, `zh`) and
locale-shaped environment values; natural-language labels are not accepted
as command arguments.
- Documented i18n and user-facing text rules for English-first source text,
stable command syntax, concise help copy, and translation-only localization.
## 1.0.1 - 2026-05-24 - Release candidate hardening

View file

@ -9,9 +9,10 @@ Complete guide for TNT developers and contributors.
3. [Building and Testing](#building-and-testing)
4. [Core Components](#core-components)
5. [Adding Features](#adding-features)
6. [Debugging](#debugging)
7. [Performance Optimization](#performance-optimization)
8. [Contributing Guidelines](#contributing-guidelines)
6. [User-Facing Text and i18n](#user-facing-text-and-i18n)
7. [Debugging](#debugging)
8. [Performance Optimization](#performance-optimization)
9. [Contributing Guidelines](#contributing-guidelines)
---
@ -395,6 +396,67 @@ case MODE_INSERT:
---
## User-Facing Text and i18n
TNT should follow Unix/open-source conventions for user-facing text:
English is the source language, command syntax is stable ASCII, and
translations are presentation only. A localized interface must never create
localized command names, localized option names, or localized configuration
keys.
### Principles
1. **English-first source text**
- Keep code identifiers, comments, command names, option names, and
documentation source in English.
- Treat English text as the canonical source text for future gettext-style
catalogs.
- Do not use translated text as a programmatic key.
2. **Stable language identifiers**
- Interactive `:lang` accepts only stable language codes: `en` and `zh`.
- Locale detection may accept locale-shaped values such as
`en_US.UTF-8`, `zh_CN.UTF-8`, `C`, and `POSIX`.
- Do not accept natural-language labels such as `english`, `chinese`,
`中文`, or `英文` as command arguments.
- If regional variants are added later, add explicit locale identifiers
such as `zh_TW` instead of overloading `zh`.
3. **Concise writing**
- Prefer imperative verbs: "Show", "Switch", "Disconnect".
- Keep command descriptions noun-like or verb-like, not explanatory prose.
- Avoid tutorial language in `:help`; put detailed behavior in `tnt(1)`.
- Keep `:help` within one command-output screen. `?` is the full key
reference.
4. **One behavior, one name**
- Do not create parallel help commands for the same task.
- Keep `:help` for the concise manual and `?` for the full key reference.
- Keep SSH exec commands small, scriptable, and stable.
5. **Translation safety**
- Use whole sentences or whole phrases; do not concatenate translated
fragments.
- Keep placeholders visible and stable, for example `%s`, `%d`,
`<user>`, and `<message>`.
- Every new user-facing string needs tests for at least English fallback
and Chinese output while this project has two UI languages.
### Current Limitations
The current `src/i18n.c` implementation is a small-project translation table
implemented in C, not a full gettext catalog. It is acceptable for two
languages, but adding more languages should first split message lookup from
language parsing and move toward catalog-like storage. Do not grow the
current approach by adding ad hoc branches for every locale.
Relevant conventions:
- POSIX locale variables: `LANG`, `LC_ALL`, `LC_MESSAGES`.
- GNU gettext source preparation: decent English, whole sentences, and
format placeholders rather than string concatenation.
---
## Debugging
### Enable Verbose SSH Logging

View file

@ -41,15 +41,12 @@ bool i18n_try_parse_lang(const char *value, help_lang_t *lang) {
return false;
}
if (starts_with_lang(value, "zh") ||
starts_with_lang(value, "cn") ||
starts_with_lang(value, "chinese")) {
if (starts_with_lang(value, "zh")) {
if (lang) *lang = LANG_ZH;
return true;
}
if (starts_with_lang(value, "en") ||
starts_with_lang(value, "english") ||
starts_with_lang(value, "c") ||
starts_with_lang(value, "posix")) {
if (lang) *lang = LANG_EN;

View file

@ -7,26 +7,18 @@ const char *manual_text_interactive(help_lang_t lang) {
"\033[1;37m名称\033[0m\n"
" TNT - SSH 终端聊天室\n"
"\n"
"\033[1;37m快速开始\033[0m\n"
" 直接输入消息Enter 发送\n"
" Esc 进入 NORMAL 浏览历史G 回到最新i 继续输入\n"
" : 输入命令q 或 Esc 关闭输出面板\n"
"\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n"
" : 运行命令;? 打开完整按键参考\n"
"\n"
"\033[1;37m命令\033[0m\n"
" :users 在线用户\n"
" :last [N] 最近消息,默认 10最多 50\n"
" :search <关键词> 搜索历史\n"
" :msg <用户> <文本> 私聊\n"
" :inbox 私聊收件箱\n"
" :nick <名字> 修改昵称\n"
" :mute-joins 静音/开启进出提示\n"
" :clear 清空命令输出\n"
" :q 断开连接\n"
" :users, :last [N], :search <词>\n"
" :msg <用户> <文本>, :inbox, :nick <名字>\n"
" :mute-joins, :clear, :q\n"
"\n"
"\033[1;37m语言\033[0m\n"
" :lang 显示当前语言\n"
" :lang zh 切换中文\n"
" :lang en 切换英文\n"
" :lang en|zh 切换语言\n"
"\n"
"\033[1;37m参见\033[0m\n"
" ? 完整按键参考\n";
@ -37,26 +29,18 @@ const char *manual_text_interactive(help_lang_t lang) {
"\033[1;37mName\033[0m\n"
" TNT - SSH terminal chat room\n"
"\n"
"\033[1;37mQuick start\033[0m\n"
" Type a message and press Enter to send\n"
" Esc enters NORMAL history browsing; G jumps latest; i types again\n"
" : enters COMMAND mode; q or Esc closes output panels\n"
"\033[1;37mUse\033[0m\n"
" Type a message and press Enter; Esc browses; G latest; i types\n"
" : runs commands; ? opens the full key reference\n"
"\n"
"\033[1;37mCommands\033[0m\n"
" :users show online users\n"
" :last [N] show recent messages, default 10, max 50\n"
" :search <keyword> search history\n"
" :msg <user> <text> whisper privately\n"
" :inbox show whispers\n"
" :nick <name> change nickname\n"
" :mute-joins toggle join/leave notices\n"
" :clear clear command output\n"
" :q disconnect\n"
" :users, :last [N], :search <keyword>\n"
" :msg <user> <text>, :inbox, :nick <name>\n"
" :mute-joins, :clear, :q\n"
"\n"
"\033[1;37mLanguage\033[0m\n"
" :lang show current language\n"
" :lang en switch to English\n"
" :lang zh switch to Chinese\n"
" :lang en|zh switch language\n"
"\n"
"\033[1;37mSee also\033[0m\n"
" ? full key reference\n";

View file

@ -21,14 +21,20 @@ TEST(parse_explicit_languages) {
assert(i18n_parse_lang("zh", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("zh_CN.UTF-8", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("cn", LANG_EN) == LANG_ZH);
assert(i18n_parse_lang("en", LANG_ZH) == LANG_EN);
assert(i18n_parse_lang("en_US.UTF-8", LANG_ZH) == LANG_EN);
assert(i18n_parse_lang("C", LANG_ZH) == LANG_EN);
assert(i18n_parse_lang("POSIX", LANG_ZH) == LANG_EN);
assert(i18n_try_parse_lang("zh", &lang) == true);
assert(lang == LANG_ZH);
assert(i18n_try_parse_lang("en", &lang) == true);
assert(lang == LANG_EN);
assert(i18n_try_parse_lang("cn", &lang) == false);
assert(i18n_try_parse_lang("english", &lang) == false);
assert(i18n_try_parse_lang("chinese", &lang) == false);
assert(i18n_try_parse_lang("中文", &lang) == false);
assert(i18n_try_parse_lang("英文", &lang) == false);
assert(i18n_try_parse_lang("fr", &lang) == false);
}
@ -44,7 +50,7 @@ TEST(parse_ignores_surrounding_whitespace) {
assert(i18n_try_parse_lang(" zh ", &lang) == true);
assert(lang == LANG_ZH);
assert(i18n_parse_lang("\ten_US.UTF-8\n", LANG_ZH) == LANG_EN);
assert(i18n_parse_lang(" english ", LANG_ZH) == LANG_EN);
assert(i18n_try_parse_lang(" english ", &lang) == false);
assert(i18n_try_parse_lang("zh CN", &lang) == false);
setenv("TNT_LANG", " zh ", 1);
@ -109,9 +115,9 @@ TEST(text_lookup_matches_language) {
assert(strstr(i18n_text(LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
"搜索") != NULL);
assert(strstr(i18n_text(LANG_EN, I18N_LANG_CURRENT_FORMAT),
"Current language") != NULL);
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(LANG_ZH, I18N_LANG_CURRENT_FORMAT),
"当前语言") != NULL);
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(LANG_EN, I18N_UNKNOWN_COMMAND_FORMAT),
"Unknown command") != NULL);
assert(strstr(i18n_text(LANG_ZH, I18N_UNKNOWN_COMMAND_FORMAT),

View file

@ -15,23 +15,40 @@
static int tests_passed = 0;
static int count_lines(const char *text) {
int lines = 0;
while (text && *text) {
if (*text == '\n') {
lines++;
}
text++;
}
return lines;
}
TEST(interactive_manual_matches_language) {
const char *en = manual_text_interactive(LANG_EN);
const char *zh = manual_text_interactive(LANG_ZH);
assert(strstr(en, "TNT(1) help") != NULL);
assert(strstr(en, "Quick start") != NULL);
assert(strstr(en, "Use") != NULL);
assert(strstr(en, "Commands") != NULL);
assert(strstr(en, ":lang en|zh") != NULL);
assert(strstr(en, ":mute-joins") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL);
assert(count_lines(en) <= 20);
assert(strstr(zh, "TNT(1) 帮助") != NULL);
assert(strstr(zh, "快速开始") != NULL);
assert(strstr(zh, "使用") != NULL);
assert(strstr(zh, "命令") != NULL);
assert(strstr(zh, ":lang en|zh") != NULL);
assert(strstr(zh, ":mute-joins") != NULL);
assert(strstr(zh, ":support") == NULL);
assert(strstr(zh, ":commands") == NULL);
assert(count_lines(zh) <= 20);
}
int main(void) {