---
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: 2025-06-11
updatedDate: 2026-09-29
category: engineering
blueprint: sme-digital-suite
blueprintLayer: L1-high-conversion-web
tags: ["i18n", "nginx", "vue", "javascript", "self-hosting"]
ogImage: "/og/adding-english-mode-to-a-chinese-only-web-app.jpg"
banner: "/banners/adding-english-mode-to-a-chinese-only-web-app.jpg"
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).*