diff --git a/public/banners/adding-english-mode-to-a-chinese-only-web-app.png b/public/banners/adding-english-mode-to-a-chinese-only-web-app.png new file mode 100644 index 0000000..4e6865f Binary files /dev/null and b/public/banners/adding-english-mode-to-a-chinese-only-web-app.png differ diff --git a/public/og/adding-english-mode-to-a-chinese-only-web-app.png b/public/og/adding-english-mode-to-a-chinese-only-web-app.png new file mode 100644 index 0000000..d100c2b Binary files /dev/null and b/public/og/adding-english-mode-to-a-chinese-only-web-app.png differ diff --git a/scripts/banner-gen/generate.mjs b/scripts/banner-gen/generate.mjs index 15cc4e7..9a904c9 100644 --- a/scripts/banner-gen/generate.mjs +++ b/scripts/banner-gen/generate.mjs @@ -685,6 +685,27 @@ BANNERS['read-only-nocodb-dashboard-for-a-remote-database'] = { ], }; +BANNERS['adding-english-mode-to-a-chinese-only-web-app'] = { + titlebar: 'root@dsm — reader-gateway · nginx:alpine', + lines: [ + { t: 'prompt', text: '$' }, { t: 'cmd', text: 'curl -s book.hoelee.com/index.html | head -1' }, + { t: 'err', text: ' ← but the UI is 100% Chinese' }, + { t: 'dim', text: 'browsers key the translate prompt off that attribute' }, + { t: 'prompt', text: '$' }, { t: 'cmd', text: 'sub_filter lang="en" → lang="zh-CN"' }, + { t: 'hl', text: 'no API exists to trigger browser translation from JS' }, + { t: 'cmd', text: 'gate-en.js · 871 zh→en labels · longest-first matching' }, + { t: 'dim', text: 'MutationObserver follows the Vue re-renders' }, + { t: 'ok', text: '→ 88% of UI labels English · app untouched ✓' }, + ], + flow: [ + { n: '1', label: 'Chinese-only SPA' }, + { n: '2', label: 'lang="en" lie', err: true }, + { n: '3', label: 'fix at the proxy' }, + { n: '4', label: 'EN pill + dictionary' }, + { n: '5', label: 'English UI ✓' }, + ], +}; + // ---------- read frontmatter ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); let category = 'devops'; diff --git a/scripts/og-gen/generate.mjs b/scripts/og-gen/generate.mjs index cc489dc..4586a27 100644 --- a/scripts/og-gen/generate.mjs +++ b/scripts/og-gen/generate.mjs @@ -214,6 +214,11 @@ TERMINALS['upgrading-codeigniter-46-to-47'] = `
 Undefined property Config\\App::$permittedURIChars → 500
$merge project-space configs by hand→ report renders ✓
`; +TERMINALS['adding-english-mode-to-a-chinese-only-web-app'] = ` +
$curl -s book.hoelee.com/index.html | grep -o lang=→ "en"
+
 the UI is 100% Chinese · browsers then never offer translate
+
$nginx sub_filter + gate-en.js · 871 zh→en labels→ 88% English ✓
`; + // ---------- read frontmatter ---------- const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`); if (!existsSync(postPath)) { diff --git a/src/content/posts/adding-english-mode-to-a-chinese-only-web-app.md b/src/content/posts/adding-english-mode-to-a-chinese-only-web-app.md new file mode 100644 index 0000000..100caed --- /dev/null +++ b/src/content/posts/adding-english-mode-to-a-chinese-only-web-app.md @@ -0,0 +1,184 @@ +--- +title: "No API for Browser Translation: Adding an English Mode to a Chinese-Only Web App" +description: "Browsers expose no JavaScript API to trigger translation — and my app declared English while being Chinese. How I injected an English mode at the reverse proxy." +pubDate: 2026-09-29 +category: engineering +tags: ["i18n", "nginx", "vue", "javascript", "self-hosting"] +ogImage: "/og/adding-english-mode-to-a-chinese-only-web-app.png" +banner: "/banners/adding-english-mode-to-a-chinese-only-web-app.png" +draft: false +--- + +Can I trigger the browser's translate feature from JavaScript? No. That answer cost me an afternoon to accept — but the more interesting discovery came first: my app was already sabotaging the browser's translation, and it did it with a single attribute. + +## What I was working on + +I run a private, invite-only reading platform at `book.hoelee.com`: a self-hosted reader (Kotlin/Spring backend, Vue single-page app) holding my library, with accounts for invited readers. The interface is Chinese-only. Not "Chinese-first" — *only*. I grepped every served bundle before believing it: no i18n library, no locale files, no language switcher. Just a Simplified/Traditional character converter and a list of English TTS voice names. + +Then I started handing out invite cards to English-speaking readers. They could register, and then they met 书架, 浏览书仓, 确定, 设置 — every control in a language they don't read. That isn't a polish problem. For half the people I invited, the product was unusable. + +One constraint shaped everything that follows: **I don't modify the app.** Its upstream repository is archived and the UI is a minified Vue bundle, so anything I patch inside the image dies the next time I swap the image. Whatever I build has to live one layer out — in the nginx gateway I already run in front of the app. + +## Finding 1: the app declares the wrong language, and that disables translation + +Before writing a single line of code, I looked at what the browser sees: + +```bash +$ curl -s https://book.hoelee.com/index.html | grep -o ']*>' + +``` + +The shell says English. The interface is 100% Chinese. + +That matters more than it looks. Chrome, Edge and Safari decide whether to *offer* translation largely from the page's declared language. A page that declares English while rendering Chinese text doesn't get the "Translate this page?" prompt — the single most useful accessibility feature for my new readers was silently switched off, and right-click → *Translate to English* is a ritual most people never learn. + +The fix is one line, applied to the response on the way out instead of inside the app: + +```nginx +location = /index.html { + # sub_filter cannot rewrite a gzipped body -> ask upstream for plain HTML + proxy_set_header Accept-Encoding ""; + sub_filter_once on; + # the app declares lang="en" while its UI is Chinese -> browsers then never + # offer Chinese -> English translation. Declare the truth. + sub_filter '' ''; + proxy_pass http://hectorqin-reader:8080; +} +``` + +Verify the same way you found it: `curl -s https://book.hoelee.com/index.html | grep -o '' ''; + proxy_pass http://hectorqin-reader:8080; +} +``` + +That's the whole deployment: a 40 KB script, loaded only on `/index.html`, so the button exists inside the app and nowhere else. The script does three things. + +### 1. Harvest the vocabulary from the app's own bundles + +The UI strings are already in the minified JavaScript as quoted literals. Extract every one that contains a CJK character and count them: + +```python +import re, collections +CJK = re.compile(r'[\u4e00-\u9fff]') +count = collections.Counter() +for bundle in bundles: + for m in re.finditer(r'"([^"\\\n]{2,40})"', bundle): + if CJK.search(m.group(1)): + count[m.group(1)] += 1 +print(len(count)) # 840 unique strings, most frequent first +``` + +840 unique strings came out: menus (书架, 书源管理), confirmations (确认要删除所选择的书籍吗?), settings labels (段落行距), error messages (本地书籍源文件不存在). The dictionary ended up at **871 entries** — the harvest plus everything I found by running the app in English mode and watching what stayed Chinese. + +### 2. Match like the DOM actually is, not like you wish it were + +Naive substring replacement mangles Chinese compounds: 书架 sits inside 加入书架, and single characters are worse (页 inside 页面). Three rules fixed it: + +- **Whole-label match first, on whitespace-stripped text.** The app stores labels like `" 书架 "` with padding, so a dictionary key of `设置` has to match `" 设置 "` in the DOM. +- **Then longest-first substring replacement**, restricted to keys of two or more characters. +- **One-character keys are whole-label only** (章, 页, 条), so they can never corrupt a longer word. + +```js +function tr(s) { + if (!s || !CJK.test(s)) return s; + var hit = EXACT[s.replace(/[\s\u3000]/g, '')]; // whole label, whitespace-normalised + if (hit) return keepWS(s, hit); + var t = s; + for (var i = 0; i < KEYS.length; i++) { // KEYS: longest-first, length >= 2 + if (t.indexOf(KEYS[i]) >= 0) t = t.split(KEYS[i]).join(DICT[KEYS[i]]); + } + // tidy count phrases: 共58个可用书源 -> "58 usable sources" + return t.replace(/共\s*(\d+)/g, '$1').replace(/(\d+)\s*个/g, '$1').replace(/(\d)([A-Za-z])/g, '$1 $2'); +} +``` + +### 3. Keep up with the framework + +It's a Vue app, so the DOM is rewritten constantly — a translation pass that runs once is a translation pass that's half wrong a second later. A `MutationObserver` with a 150 ms debounce re-runs the walk after any change. + +The pass is idempotent: once a label is English it contains no CJK, so nothing is rewritten and there is no write loop. Text nodes plus `placeholder`, `title`, `aria-label`, `alt` and input `value` attributes all get translated, which is why tooltips and the search box come along too. + +The UI is a small pill in the bottom-right corner: `EN` when off, `中文` when on, remembered in `localStorage`, with a "Hide this button" link for readers who prefer their own translator. + +## What it deliberately does not translate + +**Book text.** The reader renders EPUB content inside an iframe; my walker skips iframes by design, and I would never machine-swap a novel's prose anyway. + +Also left alone: book titles and authors, book-source names, and the group names I created myself (梯子, 精品, 正版), plus the app's poem tagline. Those are *data*, not interface. Translating the chrome is a courtesy; silently rewriting a user's own content is a different and much worse idea. + +For book text the browser's own translation is still the right tool — and it now actually gets offered, thanks to Finding 1. The button's popup says exactly that. + +## Measure it, don't eyeball it + +"Screenshots look mostly English" is not a result. I counted CJK-containing text nodes in the live DOM before and after toggling the button: + +```js +let cjk = 0, total = 0; // walker skips the injected UI + script/style +while ((n = walker.nextNode())) { + const t = n.nodeValue.trim(); + if (!t) continue; + total++; + if (/[\u4e00-\u9fff]/.test(t)) cjk++; +} +``` + +| Screen | Before | After | Translated | +|---|---|---|---| +| Login / register dialog | 80 | 12 | 85% | +| Bookshelf + settings | 259 | 60 | 77% | +| Reading page chrome | 214 | 31 | 88% | + +Everything still counted as Chinese on those screens is data — titles, authors, source names, my own group names, the tagline — which is exactly what should stay Chinese. + +## What I'd do differently + +**Don't rewrite an 871-entry dictionary by hand.** I restructured mine once and silently dropped 25 keys. The symptom looked like a logic bug: 设置 stayed Chinese while every sibling label translated correctly. The matcher was fine — the *entry* was gone. Diff the key sets programmatically before you trust a rewrite: + +```bash +python -c "print(sorted(set(old_keys) - set(new_keys)))" +``` + +**Keep automated UI checks short.** My headless runner hard-times-out at 60 seconds per call. A six-book loop came back HTTP 408 with an empty body, and my JSON parser reported a parse error rather than a timeout — I debugged the wrong thing for a while. + +**Learn the SPA's real routes before debugging it.** `#/reader/1` renders a blank shell and makes you think the app is broken; the actual route is `#/reader?bookUrl=`. + +**Mind your own bounce logic.** My gateway sends any reload back to the front page unless a `sessionStorage` pass flag is set. Switching the interface *back* to Chinese reloads the page — so the button sets that same flag first, or the reader gets thrown out of the app for changing a language setting. + +## Result + +An English-only reader now opens the platform, taps one button, and reads the interface in English. The app itself is untouched: the whole feature is a 40 KB script served by a sidecar nginx container, so the next image update cannot break it, and the browser's own translation has a page that declares its real language to work with. + +The lesson I'm keeping: **when the platform won't give you an API, look one layer out.** A reverse proxy is a legitimate place to add behaviour you can't add in the app — and it's usually the only place that survives upgrades. + +--- + +*I build and self-host platforms like this one — web apps, private libraries, internal tools for small businesses in Malaysia. If you need something similar: [WhatsApp +60 12-797 2969](https://wa.me/60127972969), [me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20platform), or [hoelee.com](https://www.hoelee.com).* diff --git a/src/content/posts/zh/adding-english-mode-to-a-chinese-only-web-app.md b/src/content/posts/zh/adding-english-mode-to-a-chinese-only-web-app.md new file mode 100644 index 0000000..5bf0d3e --- /dev/null +++ b/src/content/posts/zh/adding-english-mode-to-a-chinese-only-web-app.md @@ -0,0 +1,184 @@ +--- +title: "浏览器翻译没有 API:我给一个纯中文网页应用加上了英文模式" +description: "浏览器不提供触发翻译的 JavaScript API,而我的应用明明是中文却声明自己是英文。于是我改用反向代理,把一个英文模式注入进去。" +pubDate: 2026-09-29 +category: engineering +tags: ["i18n", "nginx", "vue", "javascript", "self-hosting"] +ogImage: "/og/adding-english-mode-to-a-chinese-only-web-app.png" +banner: "/banners/adding-english-mode-to-a-chinese-only-web-app.png" +draft: false +--- + +「能不能用 JavaScript 触发浏览器自带的翻译?」答案是**不能**。接受这一点花了我一个下午 —— 但更有意思的发现出现在前面:我的应用其实一直在破坏浏览器的翻译功能,而且只用一个属性就做到了。 + +## 我在做什么 + +我维护着一个私人的邀请制阅读平台 `book.hoelee.com`:自建的阅读器(Kotlin/Spring 后端 + Vue 单页应用),装着我自己的藏书,并给受邀读者开了账号。界面是纯中文 —— 不是「以中文为主」,而是**只有中文**。我在相信这一点之前把每个前端 bundle 都翻了一遍:没有 i18n 库、没有语言包、没有语言切换,只有一个简繁转换表和一堆英文 TTS 语音名。 + +后来我开始把邀请卡发给只说英文的读者。他们能注册,然后迎面撞上:书架、浏览书仓、确定、设置 —— 每一个控件都在他们读不懂的语言里。这不是「体验不够精致」的问题。对我邀请的一半人来说,这个产品是不可用的。 + +有一条约束决定了后面所有做法:**我不改这个应用。** 它的上游仓库已经归档,界面是压缩过的 Vue bundle —— 任何我打进镜像里的补丁,都会在下一次换镜像时消失。所以我做的东西必须待在**外面一层**:我已经架在应用前面的那个 nginx 网关。 + +## 发现一:应用声明了自己错误的语言,而这会关掉翻译 + +写代码之前,我先看看浏览器到底看到了什么: + +```bash +$ curl -s https://book.hoelee.com/index.html | grep -o ']*>' + +``` + +外壳声明英文,界面 100% 中文。 + +这件事比看上去严重。Chrome、Edge、Safari 决定「要不要**主动提示**翻译」,很大程度取决于页面声明的语言。一个声明自己是英文、却渲染中文内容的页面,不会得到那个「要翻译此页面吗?」的提示 —— 对我的新读者来说最有用的一项无障碍功能,就这么被悄悄关掉了;而「右键 → 翻译成中文/英文」是大多数人从来不知道的操作。 + +修复只要一行,而且是作用在**响应出站时**,不是打进应用里: + +```nginx +location = /index.html { + # sub_filter 不能改写 gzip 后的内容 -> 让上游返回未压缩的 HTML + proxy_set_header Accept-Encoding ""; + sub_filter_once on; + # 应用声明 lang="en" 而界面是中文 -> 浏览器因此永远不提示中译英。 + # 说出事实。 + sub_filter '' ''; + proxy_pass http://hectorqin-reader:8080; +} +``` + +用发现它的方式验证:`curl -s https://book.hoelee.com/index.html | grep -o '' ''; + proxy_pass http://hectorqin-reader:8080; +} +``` + +整个部署就是这样:一个 40 KB 的脚本,只在 `/index.html` 加载,所以按钮只存在于应用内,别处都没有。脚本做三件事。 + +### 1. 从应用自己的 bundle 里「收割」词汇表 + +界面文案本来就在压缩后的 JavaScript 里,以字符串字面量的形式存在。把所有含中日韩字符的字符串抓出来并计数: + +```python +import re, collections +CJK = re.compile(r'[\u4e00-\u9fff]') +count = collections.Counter() +for bundle in bundles: + for m in re.finditer(r'"([^"\\\n]{2,40})"', bundle): + if CJK.search(m.group(1)): + count[m.group(1)] += 1 +print(len(count)) # 840 条唯一字符串,按出现频率排序 +``` + +跑出 840 条唯一字符串:菜单(书架、书源管理)、确认框(确认要删除所选择的书籍吗?)、设置项(段落行距)、错误信息(本地书籍源文件不存在)。词典最终是 **871 条** —— 收割来的,加上我在英文模式下实际使用应用、看还有什么没翻而补上的。 + +### 2. 按 DOM 的真实样子匹配,而不是你希望的样子 + +朴素的子串替换会毁掉中文词组:书架 就藏在 加入书架 里面,单字更糟(页 藏在 页面 里)。三条规则解决了它: + +- **先做整标签匹配,基于去掉空白后的文本。** 应用里的标签是 `" 书架 "` 这种带空格的,所以词典里的 `设置` 必须能匹配 DOM 里的 `" 设置 "`。 +- **然后是最长优先的子串替换**,只允许长度 ≥ 2 的键参与。 +- **单字键只做整标签匹配**(章、页、条),这样它们永远不可能破坏更长的词。 + +```js +function tr(s) { + if (!s || !CJK.test(s)) return s; + var hit = EXACT[s.replace(/[\s\u3000]/g, '')]; // 整标签匹配,空白归一化 + if (hit) return keepWS(s, hit); + var t = s; + for (var i = 0; i < KEYS.length; i++) { // KEYS:最长优先,长度 >= 2 + if (t.indexOf(KEYS[i]) >= 0) t = t.split(KEYS[i]).join(DICT[KEYS[i]]); + } + // 收拾数量短语:共58个可用书源 -> "58 usable sources" + return t.replace(/共\s*(\d+)/g, '$1').replace(/(\d+)\s*个/g, '$1').replace(/(\d)([A-Za-z])/g, '$1 $2'); +} +``` + +### 3. 跟上框架的重绘 + +它是 Vue 应用,DOM 一直在被重写 —— 只跑一次的翻译,一秒后就有一半是错的。一个带 150 ms 防抖的 `MutationObserver`,在任何变化之后重跑一遍遍历。 + +这一遍是幂等的:标签一旦变成英文就不再含中日韩字符,于是不会被再写一次,也不会形成写入循环。文本节点之外,`placeholder`、`title`、`aria-label`、`alt` 和输入框的 `value` 也会翻译 —— 所以提示气泡和搜索框也一起变英文了。 + +UI 是右下角的一个小胶囊:关闭时显示 `EN`,开启后显示 `中文`,选择记在 `localStorage`,还提供「隐藏此按钮」给更愿意用自己翻译器的读者。 + +## 它**刻意**不翻译什么 + +**书籍正文。** 阅读器把 EPUB 内容渲染在 iframe 里,我的遍历器按设计跳过 iframe —— 而且我无论如何也不会去机器替换一本小说的正文。 + +同样保持原样的还有:书名与作者、书源名称、我自己建的分组名(梯子、精品、正版),以及应用那句诗。这些是**数据**,不是界面。翻译界面是一种体贴;悄悄改写用户自己的内容是另一回事,而且糟糕得多。 + +书籍正文仍然交给浏览器自带的翻译 —— 而且因为「发现一」,它现在真的会被提示出来。按钮的弹窗里就是这么写的。 + +## 要量,不要用眼睛看 + +「截图看起来基本是英文了」不算结果。我在开关按钮的前后,数了实时 DOM 里含中日韩字符的文本节点: + +```js +let cjk = 0, total = 0; // 遍历器跳过注入的 UI 与 script/style +while ((n = walker.nextNode())) { + const t = n.nodeValue.trim(); + if (!t) continue; + total++; + if (/[\u4e00-\u9fff]/.test(t)) cjk++; +} +``` + +| 界面 | 之前 | 之后 | 已翻译 | +|---|---|---|---| +| 登录 / 注册对话框 | 80 | 12 | 85% | +| 书架 + 设置 | 259 | 60 | 77% | +| 阅读页界面 | 214 | 31 | 88% | + +那些界面里仍被计为中文的,全部是数据 —— 书名、作者、书源名、我自己的分组名、那句诗 —— 正是应该保持中文的部分。 + +## 我会怎么做得不一样 + +**不要用手工重写一份 871 条的词典。** 我重构过一次,静默丢了 25 个键。症状看起来像逻辑 bug:设置 还是中文,而它的每个兄弟标签都翻译正常。匹配器没问题 —— 是**条目**不见了。在你相信一次重写之前,用程序对比键集合: + +```bash +python -c "print(sorted(set(old_keys) - set(new_keys)))" +``` + +**自动化 UI 检查要写短。** 我的无头运行器每次调用硬超时 60 秒。一个遍历六本书的循环返回 HTTP 408 和空 body,而我的 JSON 解析器报的是「解析错误」而不是「超时」—— 我一度在查错的东西。 + +**调试之前先搞清楚 SPA 的真实路由。** `#/reader/1` 会渲染出一个空白外壳,让人以为应用坏了;真正的路由是 `#/reader?bookUrl=`。 + +**注意你自己的跳转逻辑。** 我的网关会把任何刷新送回前台页,除非设置了 `sessionStorage` 的通行标记。把界面切**回**中文会重载页面 —— 所以按钮会先设好同一个标记,否则读者会因为改了个语言设置被踢出应用。 + +## 结果 + +一个只懂英文的读者,现在打开平台、点一下按钮,就能用英文读界面。应用本身没有被改动:整个功能只是一个由 sidecar nginx 容器提供的 40 KB 脚本,所以下一次换镜像不可能弄坏它;而浏览器自带的翻译,终于有一个会声明自己真实语言的页面可以配合。 + +我留下的经验:**当平台不给你 API 时,往外看一层。** 反向代理是添加「应用里加不了的行为」的正当位置 —— 而且通常是唯一能在升级中存活的位置。 + +--- + +*我为马来西亚的中小企业搭建并自托管这类平台 —— 网页应用、私人书库、内部工具。有需要可以找我:[WhatsApp +60 12-797 2969](https://wa.me/60127972969)、[me@hoelee.com](mailto:me@hoelee.com?subject=Self-hosted%20platform),或 [hoelee.com](https://www.hoelee.com)。*