This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -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: '<html lang="en"> ← 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';
|
||||
|
||||
@@ -214,6 +214,11 @@ TERMINALS['upgrading-codeigniter-46-to-47'] = `
|
||||
<div class="line"><span class="prompt"> </span><span class="err">Undefined property Config\\App::$permittedURIChars → 500</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">merge project-space configs by hand</span><span class="fix">→ report renders ✓</span></div>`;
|
||||
|
||||
TERMINALS['adding-english-mode-to-a-chinese-only-web-app'] = `
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">curl -s book.hoelee.com/index.html | grep -o lang=</span><span class="err">→ "en"</span></div>
|
||||
<div class="line"><span class="prompt"> </span><span class="err">the UI is 100% Chinese · browsers then never offer translate</span></div>
|
||||
<div class="line"><span class="prompt">$</span><span class="cmd">nginx sub_filter + gate-en.js · 871 zh→en labels</span><span class="fix">→ 88% English ✓</span></div>`;
|
||||
|
||||
// ---------- read frontmatter ----------
|
||||
const postPath = join(ROOT, 'src', 'content', 'posts', `${slug}.md`);
|
||||
if (!existsSync(postPath)) {
|
||||
|
||||
@@ -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 '<html[^>]*>'
|
||||
<html lang="en">
|
||||
```
|
||||
|
||||
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 '<html lang="en">' '<html lang="zh-CN">';
|
||||
proxy_pass http://hectorqin-reader:8080;
|
||||
}
|
||||
```
|
||||
|
||||
Verify the same way you found it: `curl -s https://book.hoelee.com/index.html | grep -o '<html lang="[^"]*"'` → `<html lang="zh-CN"`. The declared language is what the translate prompt keys off, so with the page finally declaring Chinese the offer can fire correctly. (The exact prompt still depends on each browser and its language settings — what I verified on the wire is the attribute itself.) Because the rewrite happens at the proxy, it survives every app update.
|
||||
|
||||
## Finding 2: there is no browser-translate API
|
||||
|
||||
With the page honest again, the obvious next question was whether I could *offer* translation myself — a button that does what the browser's menu item does. There is no such API. Browser translation is a UI-level feature; a page cannot invoke it, and no web-exposed surface exists to script it.
|
||||
|
||||
So I worked through the alternatives and rejected them one by one:
|
||||
|
||||
| Approach | Why I dropped it |
|
||||
|---|---|
|
||||
| Rewrite the app's JS bundles to swap strings | Couples me to minified internals; the next image breaks it. Also violates the no-modify rule in spirit. |
|
||||
| Proxy the site through `translate.goog` | It's a **different origin**. The app keeps its auth token in `localStorage`, which is per-origin — readers would land logged out, and the service worker/PWA breaks. Every API call would also round-trip through Google. |
|
||||
| Tell readers to right-click → Translate | Works for *book text*, but it's a per-visit, per-browser ritual that does nothing for someone who doesn't know it exists. Not good enough for a product where I control the whole stack. |
|
||||
| Fork the app and add i18n properly | Weeks of work on an archived codebase, for an interface I'd have to re-translate on every upstream change. |
|
||||
|
||||
What's left is the unglamorous one: translate the interface myself, in the browser, with a dictionary — and wrap it in a button that only exists inside the app.
|
||||
|
||||
## The fix: a dictionary and a button, injected at the gateway
|
||||
|
||||
The gateway already serves my front page, so it can serve one more static file and inject a script tag into the app shell only:
|
||||
|
||||
```nginx
|
||||
location = /gate-en.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=300" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
# ... the lang rewrite above ...
|
||||
sub_filter '</head>' '<script src="/gate-en.js" defer></script></head>';
|
||||
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=<urlencoded>`.
|
||||
|
||||
**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), [[email protected]](mailto:[email protected]?subject=Self-hosted%20platform), or [hoelee.com](https://www.hoelee.com).*
|
||||
@@ -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 '<html[^>]*>'
|
||||
<html lang="en">
|
||||
```
|
||||
|
||||
外壳声明英文,界面 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 '<html lang="en">' '<html lang="zh-CN">';
|
||||
proxy_pass http://hectorqin-reader:8080;
|
||||
}
|
||||
```
|
||||
|
||||
用发现它的方式验证:`curl -s https://book.hoelee.com/index.html | grep -o '<html lang="[^"]*"'` → `<html lang="zh-CN"`。声明的语言正是翻译提示的判断依据,页面终于声明中文之后,提示才可能正常出现。(具体提示仍取决于各浏览器与其语言设置 —— 我在链路上验证的是那个属性本身。)因为改写发生在代理层,应用怎么升级都不会失效。
|
||||
|
||||
## 发现二:浏览器翻译没有 API
|
||||
|
||||
页面老实之后,下一个问题自然是:我能不能自己提供一个按钮,做浏览器菜单里那件事?**没有这样的 API。** 浏览器翻译是 UI 层功能,网页无法调用它,也没有任何暴露给网页的接口可以脚本化它。
|
||||
|
||||
于是我逐个排除了替代方案:
|
||||
|
||||
| 方案 | 为什么放弃 |
|
||||
|---|---|
|
||||
| 改写应用的 JS bundle 来替换文案 | 把自己绑死在压缩后的内部实现上,下次换镜像就坏。也违背了「不改应用」的原则。 |
|
||||
| 用 `translate.goog` 代理整站 | 那是**另一个源(origin)**。应用的登录令牌存在 `localStorage` 里,而它是按源隔离的 —— 读者会变成未登录,Service Worker / PWA 也会坏掉;而且每个 API 调用都要绕道 Google。 |
|
||||
| 教读者右键 → 翻译 | 对**书籍正文**有用,但它是一次一浏览器的手动仪式,对根本不知道有这功能的人毫无帮助。对一个我自己掌控全栈的产品来说不够。 |
|
||||
| Fork 应用,正经加 i18n | 在一个已归档的代码库上做几周,而且上游一变我就得重译一遍。 |
|
||||
|
||||
剩下的就是最不花哨的那条路:自己用一个词典在浏览器里翻译界面 —— 再把它包进一个只在应用内出现的按钮。
|
||||
|
||||
## 做法:词典 + 按钮,在网关层注入
|
||||
|
||||
网关本来就在提供我的前台页面,那它再多提供一个静态文件、只往应用外壳里注入一个 script 标签就行:
|
||||
|
||||
```nginx
|
||||
location = /gate-en.js {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=300" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
# ... 上面的 lang 改写 ...
|
||||
sub_filter '</head>' '<script src="/gate-en.js" defer></script></head>';
|
||||
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=<urlencoded>`。
|
||||
|
||||
**注意你自己的跳转逻辑。** 我的网关会把任何刷新送回前台页,除非设置了 `sessionStorage` 的通行标记。把界面切**回**中文会重载页面 —— 所以按钮会先设好同一个标记,否则读者会因为改了个语言设置被踢出应用。
|
||||
|
||||
## 结果
|
||||
|
||||
一个只懂英文的读者,现在打开平台、点一下按钮,就能用英文读界面。应用本身没有被改动:整个功能只是一个由 sidecar nginx 容器提供的 40 KB 脚本,所以下一次换镜像不可能弄坏它;而浏览器自带的翻译,终于有一个会声明自己真实语言的页面可以配合。
|
||||
|
||||
我留下的经验:**当平台不给你 API 时,往外看一层。** 反向代理是添加「应用里加不了的行为」的正当位置 —— 而且通常是唯一能在升级中存活的位置。
|
||||
|
||||
---
|
||||
|
||||
*我为马来西亚的中小企业搭建并自托管这类平台 —— 网页应用、私人书库、内部工具。有需要可以找我:[WhatsApp +60 12-797 2969](https://wa.me/60127972969)、[[email protected]](mailto:[email protected]?subject=Self-hosted%20platform),或 [hoelee.com](https://www.hoelee.com)。*
|
||||
Reference in New Issue
Block a user