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)。*