init: GitHub 中文化 (繁體) userscript v0.15
This commit is contained in:
158
DEVELOPMENT.md
Normal file
158
DEVELOPMENT.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# GitHub 中文化 (繁體) UserScript — 開發紀錄
|
||||||
|
|
||||||
|
一份給 ScriptCat / Tampermonkey 用的 GitHub 介面繁體中文化腳本,檔案在 `github-zh-tw.user.js`。本文件紀錄從 v0.6 一路迭代到 v0.15 的過程、遇到的坑與解法。
|
||||||
|
|
||||||
|
## 設計原則
|
||||||
|
|
||||||
|
1. **只翻 GitHub 介面本身,不動使用者內容。** 檔名、commit 訊息、issue 標題、使用者名稱、topic 標籤、repo 描述、README、程式碼一律保留原樣。
|
||||||
|
2. **不破壞功能。** 搜尋語法(`is:issue state:open`)必須維持英文,否則點擊篩選會失效。
|
||||||
|
3. **SPA 友善。** GitHub 用 Turbo/pushState,切頁不會重載腳本,需要自己監聽。
|
||||||
|
4. **效能優先。** 預編譯 regex、debounce、只處理必要節點。
|
||||||
|
|
||||||
|
## 架構概念
|
||||||
|
|
||||||
|
三層替換:
|
||||||
|
|
||||||
|
```
|
||||||
|
text
|
||||||
|
├─ TIME_AGO_REGEX "3 days ago" → "3 天前"
|
||||||
|
├─ WORD_BOUNDARY_REGEX 詞庫批次替換(長詞優先、大小寫不敏感)
|
||||||
|
└─ ABBREV_REGEX "62.8k" → "6.3 萬"
|
||||||
|
```
|
||||||
|
|
||||||
|
兩層跳過:
|
||||||
|
|
||||||
|
```
|
||||||
|
shouldSkipTextAncestor(el)
|
||||||
|
├─ SKIP_TAGS SCRIPT / STYLE / CODE / PRE / INPUT / TEXTAREA ...
|
||||||
|
├─ isContentEditable / role="textbox"
|
||||||
|
└─ SKIP_CONTEXT_SELECTOR closest() 快速判斷使用者內容脈絡
|
||||||
|
```
|
||||||
|
|
||||||
|
掃描策略:`TreeWalker` 走所有文字節點 + `querySelectorAll(ATTR_SELECTOR)` 補翻屬性。
|
||||||
|
|
||||||
|
## 版本演進
|
||||||
|
|
||||||
|
### v0.6(初版)
|
||||||
|
使用者提供,基本能翻導覽列。用 `setInterval(500ms)` 輪詢 + MutationObserver flag。
|
||||||
|
|
||||||
|
**問題:** 詞庫太少、每個詞跑一次 regex 效能差、沒處理 SPA。
|
||||||
|
|
||||||
|
### v0.7
|
||||||
|
- 擴充詞庫(Settings、Pulls、Commits、Branches…)
|
||||||
|
- 使用預編譯的 `WORD_BOUNDARY_REGEX` 一次替換
|
||||||
|
- 拿掉 setInterval 輪詢,改 MutationObserver + debounce
|
||||||
|
- Hook `pushState` / `replaceState`、監聽 `turbo:load`
|
||||||
|
|
||||||
|
### v0.8
|
||||||
|
**Bug:** `stars` / `watching` / `forks` 小寫沒被翻。
|
||||||
|
**原因:** regex 只有 `g` 沒 `i` 旗標。
|
||||||
|
**修:** 改 `gi`,搭配 `LOWER_MAP` 做大小寫不敏感查表。
|
||||||
|
|
||||||
|
**Bug:** `Security and quality` 變 `安全 and quality`。
|
||||||
|
**原因:** 只有 `Security` 在詞庫,`and quality` 不在。
|
||||||
|
**修:** 加整句 `Security and quality` → `安全與品質`。長詞優先排序會先吃掉整句。
|
||||||
|
|
||||||
|
### v0.9(數字格式化)
|
||||||
|
使用者要求「62.8k」改成更好看的格式。
|
||||||
|
|
||||||
|
新增 `formatAbbrev`:
|
||||||
|
- `≥ 1 萬` → `X.X 萬`(≥100 萬去掉小數)
|
||||||
|
- `< 1 萬` → 千分號 `7,800`
|
||||||
|
- 純數字不動
|
||||||
|
|
||||||
|
**額外問題:** `<strong>62.8k</strong>` 沒被掃到。
|
||||||
|
**原因:** SCAN_SELECTORS 沒包 `<strong>`。
|
||||||
|
**修:** 加入 `strong, b, em, h1-h4, li`。
|
||||||
|
|
||||||
|
### v0.10
|
||||||
|
**Bug:** 按鈕上的 `7.8k` / `62.8k`(在 `<span class="Counter">` 裡)沒格式化。
|
||||||
|
**原因:** `Counter` 在 skip 清單。
|
||||||
|
**修:** 移除 `Counter` skip。純數字本來就不會被誤翻,`k/M` 後綴才會處理。
|
||||||
|
|
||||||
|
### v0.11(分支頁)
|
||||||
|
Branches 頁大量詞彙沒翻:Default、Active、Stale、All、Updated、Check status、Behind、Ahead 等。
|
||||||
|
|
||||||
|
也加了相對時間 regex:
|
||||||
|
```js
|
||||||
|
TIME_AGO_REGEX = /\b(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago\b/gi
|
||||||
|
```
|
||||||
|
|
||||||
|
**修 `Active 分支清單` 的半翻:** 把 `Branches` → `分支`(中文無複數差異),加整句 `Active branches` → `活躍分支`。
|
||||||
|
|
||||||
|
### v0.12(TreeWalker 重構)
|
||||||
|
**Bug:** `<th>` / `<td>` / `<relative-time>` 的文字沒翻,`<input>` 的 placeholder 也沒翻。
|
||||||
|
**原因:** SCAN_SELECTORS 白名單太窄;`<input>` 整個被 SKIP_TAGS 跳過,連屬性都沒碰。
|
||||||
|
|
||||||
|
**大改:**
|
||||||
|
1. 拿掉 SCAN_SELECTORS,改用 `TreeWalker` 走所有文字節點
|
||||||
|
2. 屬性翻譯獨立於文字節點跳過邏輯 — `<input>` 文字跳過但 placeholder 仍翻
|
||||||
|
3. `shouldSkipTextAncestor` 改成走 parent chain,比單一元素判斷更穩
|
||||||
|
|
||||||
|
### v0.13
|
||||||
|
補空狀態整句:`No branches match the search` → `沒有分支符合搜尋條件` 等。
|
||||||
|
|
||||||
|
**教訓:** 差點加了 `of` → `/共`,但 `of` 會破壞「Member of X」「out of」等句型,移除。同理 `Page`、`Showing` 也太通用不加。
|
||||||
|
|
||||||
|
### v0.14(使用者內容保護)
|
||||||
|
使用者抱怨檔名、commit 訊息、repo 描述被翻(`授權` / `自述檔.md` / `更新.sh` / `Initial 提交` / `全部 IN ONE Hacking Tool For Hackers`)。
|
||||||
|
|
||||||
|
**新增 `SKIP_CONTEXT_SELECTOR`:**
|
||||||
|
- 檔名:`.react-directory-filename-column`、`a[href*="/blob/"]`、`a[href*="/tree/"]`
|
||||||
|
- Commit:`a[href*="/commit/"]`、`[data-testid="latest-commit-details"]`
|
||||||
|
- Repo 描述:`.BorderGrid-cell > p`、`.f4.my-3`
|
||||||
|
- Topics:`a.topic-tag`
|
||||||
|
- README:`.markdown-body`
|
||||||
|
- 程式碼:`.blob-code-inner`、`.highlight`、`.react-code-lines`
|
||||||
|
- Issue / PR 標題:`.markdown-title`、`.js-issue-title`
|
||||||
|
|
||||||
|
用 `el.closest(SKIP_CONTEXT_SELECTOR)` 一次判斷祖先脈絡。
|
||||||
|
|
||||||
|
### v0.15(Issues 頁)
|
||||||
|
多個新問題:
|
||||||
|
1. `is:issue state:開啟中` — 搜尋語法被翻,**破壞功能**
|
||||||
|
2. `karanraza608-星標` — 使用者名稱被翻
|
||||||
|
3. 下拉選單沒翻(Sort by / Newest / Oldest…)
|
||||||
|
4. `read the 貢獻 guidelines` — 半翻
|
||||||
|
5. Dismiss / Want to contribute 沒翻
|
||||||
|
|
||||||
|
**新增跳過脈絡:**
|
||||||
|
- 使用者:`a[data-hovercard-type="user"|"organization"]`、`a[data-hovercard-url*="/users/"]`
|
||||||
|
- 搜尋:`[role="searchbox"]`、`[role="combobox"]`、語法 token `.pl-c1` 等
|
||||||
|
- Labels:`.IssueLabel`、`.Label`
|
||||||
|
|
||||||
|
**新增詞庫:** `Sort by` / `Order` / `Newest` / `Oldest` / `Created on` / `Last updated` / `Total comments` / `Best match` / `Reactions` / `Recently updated` / `Dismiss` / `Want to contribute to` / 整句 `If you have a bug or an idea, read the contributing guidelines before opening an issue`。
|
||||||
|
|
||||||
|
## 關鍵技術點
|
||||||
|
|
||||||
|
### 大小寫不敏感 + 保留原大小寫?
|
||||||
|
做不到。因為 `Open` (tab) 和 `opened` (issue meta 動詞) 經過 `gi` + LOWER_MAP 會用同一個翻譯。只能二選一。目前選「保留 tab 翻譯」,動詞保留英文。
|
||||||
|
|
||||||
|
### 長詞優先
|
||||||
|
```js
|
||||||
|
const sorted = [...TRANSLATION_MAP.keys()]
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
```
|
||||||
|
搭配 regex 的回溯(backtracking),長詞失敗時會嘗試短詞。讓「Active branches」整句優先於「Branches」單字。
|
||||||
|
|
||||||
|
### 屬性裡的 `...` 結尾
|
||||||
|
`Search branches...` 放進詞庫但 `\b...\b` 的尾端 `\b` 不會成立(`.` 是非字元 + 後方結尾也非字元)。解法是詞庫只放 `Search branches`,regex 會在 `\b` 前停下,保留後面的 `...`。
|
||||||
|
|
||||||
|
### React 元件的 key 變動
|
||||||
|
GitHub 的 React class 常含 hash(如 `SearchInput-module__searchInput_f5Xpk`)。不靠這些,改靠穩定的 `role`、`data-testid`、`data-hovercard-*`、`[href*="/blob/"]`。
|
||||||
|
|
||||||
|
## 目前已知限制
|
||||||
|
|
||||||
|
- **大小寫衝突**:同詞不同語境無法分別翻譯。
|
||||||
|
- **使用者自定 label / project 名稱**:會看內容決定,但已用 `.IssueLabel` / `.Label` 跳過彩色 label。
|
||||||
|
- **Issue 標題**:刻意保留英文(屬使用者內容)。
|
||||||
|
- **新 UI 改版**:如果 GitHub 改變 class / data-testid,跳過規則可能失效。
|
||||||
|
|
||||||
|
## 檔案
|
||||||
|
|
||||||
|
- `github-zh-tw.user.js` — 腳本本體
|
||||||
|
- `DEVELOPMENT.md` — 本文件
|
||||||
|
|
||||||
|
## 安裝
|
||||||
|
|
||||||
|
在 ScriptCat / Tampermonkey「新增腳本」,貼上 `github-zh-tw.user.js` 全文。或用「從本地檔案匯入」。
|
||||||
471
github-zh-tw.user.js
Normal file
471
github-zh-tw.user.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
// ==UserScript==
|
||||||
|
// @name GitHub 中文化 (繁體)
|
||||||
|
// @namespace https://github.com/scriptscat/scriptcat
|
||||||
|
// @version 0.15
|
||||||
|
// @description 幫 GitHub 導覽列、按鈕、屬性換成繁體中文,支援 SPA 動態更新,不亂翻、不卡頓。
|
||||||
|
// @match https://github.com/*
|
||||||
|
// @run-at document-idle
|
||||||
|
// @grant none
|
||||||
|
// ==/UserScript==
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const TRANSLATION_MAP = new Map([
|
||||||
|
// 倉庫導覽
|
||||||
|
['Code', '原始碼'],
|
||||||
|
['Issues', '問題'],
|
||||||
|
['Pull requests', '拉取請求'],
|
||||||
|
['Pull request', '拉取請求'],
|
||||||
|
['Pulls', '拉取請求'],
|
||||||
|
['Discussions', '討論'],
|
||||||
|
['Actions', '動作'],
|
||||||
|
['Projects', '專案'],
|
||||||
|
['Security', '安全'],
|
||||||
|
['Security and quality', '安全與品質'],
|
||||||
|
['Code scanning', '程式碼掃描'],
|
||||||
|
['Secret scanning', '密鑰掃描'],
|
||||||
|
['Dependabot', 'Dependabot'],
|
||||||
|
['Insights', '洞察'],
|
||||||
|
['Pulse', '脈動'],
|
||||||
|
['Settings', '設定'],
|
||||||
|
['Wiki', '維基'],
|
||||||
|
['Overview', '總覽'],
|
||||||
|
['Repositories', '倉庫'],
|
||||||
|
['Repository', '倉庫'],
|
||||||
|
['Followers', '追蹤者'],
|
||||||
|
|
||||||
|
// 關注 / 分支 / 星標
|
||||||
|
['Watch', '關注'],
|
||||||
|
['Watching', '關注中'],
|
||||||
|
['Unwatch', '取消關注'],
|
||||||
|
['Fork', '分支'],
|
||||||
|
['Forks', '分支數'],
|
||||||
|
['Forked', '已分支'],
|
||||||
|
['Star', '星標'],
|
||||||
|
['Starred', '已標星'],
|
||||||
|
['Stars', '星標數'],
|
||||||
|
['Unstar', '取消星標'],
|
||||||
|
['stars', '星標'],
|
||||||
|
['watching', '關注中'],
|
||||||
|
['forks', '分支'],
|
||||||
|
['Sponsor', '贊助'],
|
||||||
|
['Sponsors', '贊助者'],
|
||||||
|
['Sponsor this project', '贊助此專案'],
|
||||||
|
['Learn more about GitHub Sponsors', '進一步了解 GitHub Sponsors'],
|
||||||
|
|
||||||
|
// 倉庫頁面側欄
|
||||||
|
['About', '關於'],
|
||||||
|
['Readme', '自述檔'],
|
||||||
|
['License', '授權'],
|
||||||
|
['MIT license', 'MIT 授權'],
|
||||||
|
['Apache-2.0 license', 'Apache-2.0 授權'],
|
||||||
|
['GPL-3.0 license', 'GPL-3.0 授權'],
|
||||||
|
['BSD-3-Clause license', 'BSD-3-Clause 授權'],
|
||||||
|
['Activity', '活動'],
|
||||||
|
['Custom properties', '自訂屬性'],
|
||||||
|
['Releases', '版本'],
|
||||||
|
['No releases published', '尚未發佈版本'],
|
||||||
|
['Contributors', '貢獻者'],
|
||||||
|
['Languages', '語言'],
|
||||||
|
['Packages', '套件'],
|
||||||
|
['No packages published', '尚未發佈套件'],
|
||||||
|
['Deployments', '部署'],
|
||||||
|
['Environments', '環境'],
|
||||||
|
['Report repository', '檢舉此倉庫'],
|
||||||
|
['Resources', '資源'],
|
||||||
|
['Citation', '引用'],
|
||||||
|
['Cite this repository', '引用此倉庫'],
|
||||||
|
['Contributing', '貢獻'],
|
||||||
|
['Code of conduct', '行為準則'],
|
||||||
|
|
||||||
|
// 檔案列表操作
|
||||||
|
['Go to file', '前往檔案'],
|
||||||
|
['Add file', '新增檔案'],
|
||||||
|
['Create new file', '新增檔案'],
|
||||||
|
['Upload files', '上傳檔案'],
|
||||||
|
['Find file', '搜尋檔案'],
|
||||||
|
['Clone', '複製'],
|
||||||
|
['Download ZIP', '下載 ZIP'],
|
||||||
|
['Open with GitHub Desktop', '以 GitHub Desktop 開啟'],
|
||||||
|
['Latest commit', '最新提交'],
|
||||||
|
['Commits', '提交'],
|
||||||
|
['Commit', '提交'],
|
||||||
|
['Branches', '分支'],
|
||||||
|
['Branch', '分支'],
|
||||||
|
['Default', '預設'],
|
||||||
|
['Active', '活躍'],
|
||||||
|
['Stale', '過時'],
|
||||||
|
['All', '全部'],
|
||||||
|
['Yours', '我的'],
|
||||||
|
['Active branches', '活躍分支'],
|
||||||
|
['Stale branches', '過時分支'],
|
||||||
|
['All branches', '所有分支'],
|
||||||
|
['Your branches', '我的分支'],
|
||||||
|
['Search branches', '搜尋分支'],
|
||||||
|
['Search branches...', '搜尋分支…'],
|
||||||
|
['New branch', '新增分支'],
|
||||||
|
['Delete branch', '刪除分支'],
|
||||||
|
['Rename branch', '重新命名分支'],
|
||||||
|
['Restore branch', '還原分支'],
|
||||||
|
['View all branches', '檢視所有分支'],
|
||||||
|
['No branches match the search', '沒有分支符合搜尋條件'],
|
||||||
|
['No branches', '沒有分支'],
|
||||||
|
['No results matched your search', '沒有結果符合你的搜尋'],
|
||||||
|
['No results found', '找不到結果'],
|
||||||
|
['Nothing to show', '沒有可顯示的內容'],
|
||||||
|
['No commits', '尚無提交'],
|
||||||
|
['No issues', '沒有問題'],
|
||||||
|
['No pull requests', '沒有拉取請求'],
|
||||||
|
['No open issues', '沒有開啟中的問題'],
|
||||||
|
['No open pull requests', '沒有開啟中的拉取請求'],
|
||||||
|
['No tags', '沒有標籤'],
|
||||||
|
['No matches found', '找不到符合項目'],
|
||||||
|
['Previous', '上一頁'],
|
||||||
|
['Next', '下一頁'],
|
||||||
|
['Loading', '載入中'],
|
||||||
|
['Updated', '更新時間'],
|
||||||
|
['Check status', '檢查狀態'],
|
||||||
|
['Behind', '落後'],
|
||||||
|
['Ahead', '領先'],
|
||||||
|
['Pull request', '拉取請求'],
|
||||||
|
['Tags', '標籤'],
|
||||||
|
['Tag', '標籤'],
|
||||||
|
|
||||||
|
// PR / Issue 常見按鈕
|
||||||
|
['New issue', '開新問題'],
|
||||||
|
['New pull request', '開新拉取請求'],
|
||||||
|
['Compare', '比較'],
|
||||||
|
['Merge', '合併'],
|
||||||
|
['Merge pull request', '合併拉取請求'],
|
||||||
|
['Squash and merge', '壓縮後合併'],
|
||||||
|
['Rebase and merge', '重定基底後合併'],
|
||||||
|
['Create a merge commit', '建立合併提交'],
|
||||||
|
['Close issue', '關閉問題'],
|
||||||
|
['Close pull request', '關閉拉取請求'],
|
||||||
|
['Reopen issue', '重新開啟問題'],
|
||||||
|
['Comment', '留言'],
|
||||||
|
['Review changes', '審查變更'],
|
||||||
|
['Files changed', '變更檔案'],
|
||||||
|
['Conversation', '對話'],
|
||||||
|
['Checks', '檢查'],
|
||||||
|
|
||||||
|
// 列表篩選
|
||||||
|
['Open', '開啟中'],
|
||||||
|
['Closed', '已關閉'],
|
||||||
|
['Author', '作者'],
|
||||||
|
['Labels', '標籤'],
|
||||||
|
['Label', '標籤'],
|
||||||
|
['Milestones', '里程碑'],
|
||||||
|
['Milestone', '里程碑'],
|
||||||
|
['Assignee', '指派對象'],
|
||||||
|
['Assignees', '指派對象'],
|
||||||
|
['Reviewer', '審查者'],
|
||||||
|
['Reviewers', '審查者'],
|
||||||
|
['Sort', '排序'],
|
||||||
|
['Sort by', '排序依據'],
|
||||||
|
['Order', '順序'],
|
||||||
|
['Newest', '最新'],
|
||||||
|
['Oldest', '最舊'],
|
||||||
|
['Created on', '建立時間'],
|
||||||
|
['Last updated', '最後更新時間'],
|
||||||
|
['Most commented', '留言最多'],
|
||||||
|
['Total comments', '留言總數'],
|
||||||
|
['Best match', '最佳匹配'],
|
||||||
|
['Reactions', '反應'],
|
||||||
|
['Most reactions', '反應最多'],
|
||||||
|
['Recently updated', '最近更新'],
|
||||||
|
['Dismiss', '關閉提示'],
|
||||||
|
['Want to contribute to', '想貢獻給'],
|
||||||
|
['If you have a bug or an idea, read the contributing guidelines before opening an issue', '若您發現錯誤或有新想法,請先閱讀貢獻指引再開啟問題'],
|
||||||
|
['read the contributing guidelines', '請閱讀貢獻指引'],
|
||||||
|
['contributing guidelines', '貢獻指引'],
|
||||||
|
['before opening an issue', '再開啟問題'],
|
||||||
|
['Never', '從未'],
|
||||||
|
['Everyone', '所有人'],
|
||||||
|
['Anyone', '任何人'],
|
||||||
|
|
||||||
|
// 全站導覽 / 使用者選單
|
||||||
|
['Pull', '拉取'],
|
||||||
|
['Marketplace', '市集'],
|
||||||
|
['Explore', '探索'],
|
||||||
|
['Your profile', '個人頁面'],
|
||||||
|
['Your repositories', '我的倉庫'],
|
||||||
|
['Your organizations', '我的組織'],
|
||||||
|
['Your projects', '我的專案'],
|
||||||
|
['Your stars', '我的星標'],
|
||||||
|
['Your gists', '我的程式片段'],
|
||||||
|
['Your sponsors', '我的贊助者'],
|
||||||
|
['Upgrade', '升級'],
|
||||||
|
['Feature preview', '功能預覽'],
|
||||||
|
['Help', '說明'],
|
||||||
|
['Sign out', '登出'],
|
||||||
|
['Sign in', '登入'],
|
||||||
|
|
||||||
|
// 通用動作
|
||||||
|
['Save', '儲存'],
|
||||||
|
['Cancel', '取消'],
|
||||||
|
['Delete', '刪除'],
|
||||||
|
['Edit', '編輯'],
|
||||||
|
['Update', '更新'],
|
||||||
|
['Create', '建立'],
|
||||||
|
['Submit', '送出'],
|
||||||
|
['Search', '搜尋'],
|
||||||
|
['Filter', '篩選'],
|
||||||
|
['Filters', '篩選'],
|
||||||
|
['Follow', '追蹤'],
|
||||||
|
['Following', '追蹤中'],
|
||||||
|
['Unfollow', '取消追蹤'],
|
||||||
|
['Block or Report', '封鎖或檢舉'],
|
||||||
|
|
||||||
|
// 頁尾 / 無障礙
|
||||||
|
['Footer', '頁尾'],
|
||||||
|
['Footer navigation', '頁尾導覽'],
|
||||||
|
['GitHub Homepage', 'GitHub 首頁'],
|
||||||
|
['Terms', '服務條款'],
|
||||||
|
['Privacy', '隱私'],
|
||||||
|
['Status', '狀態'],
|
||||||
|
['Community', '社群'],
|
||||||
|
['Docs', '文件'],
|
||||||
|
['Contact', '聯絡我們'],
|
||||||
|
['Manage cookies', '管理 Cookie'],
|
||||||
|
['Do not share my personal information', '不要分享我的個人資訊'],
|
||||||
|
['Skip to content', '跳至內容'],
|
||||||
|
|
||||||
|
// 相對時間
|
||||||
|
['yesterday', '昨天'],
|
||||||
|
['today', '今天'],
|
||||||
|
['tomorrow', '明天'],
|
||||||
|
['now', '剛剛'],
|
||||||
|
['just now', '剛才'],
|
||||||
|
['last week', '上週'],
|
||||||
|
['last month', '上個月'],
|
||||||
|
['last year', '去年'],
|
||||||
|
['this week', '本週'],
|
||||||
|
['this month', '本月'],
|
||||||
|
['this year', '今年'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TIME_UNITS = {
|
||||||
|
second: '秒', minute: '分鐘', hour: '小時',
|
||||||
|
day: '天', week: '週', month: '個月', year: '年',
|
||||||
|
};
|
||||||
|
const TIME_AGO_REGEX = /\b(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago\b/gi;
|
||||||
|
const TIME_AGO_ARTICLE_REGEX = /\b(a|an)\s+(second|minute|hour|day|week|month|year)\s+ago\b/gi;
|
||||||
|
|
||||||
|
// 這些標籤的文字節點不翻(避免動到原始碼、編輯器);但它們的屬性(如 placeholder)仍會翻。
|
||||||
|
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'CODE', 'PRE', 'TEXTAREA', 'INPUT', 'NOSCRIPT']);
|
||||||
|
const ATTRIBUTES_TO_TRANSLATE = ['data-content', 'aria-label', 'title', 'placeholder'];
|
||||||
|
const ATTR_SELECTOR = '[data-content], [aria-label], [title], [placeholder]';
|
||||||
|
|
||||||
|
// 這些區塊內的文字節點(屬於使用者內容:檔名、commit 訊息、倉庫描述、README、程式碼…)一律跳過。
|
||||||
|
const SKIP_CONTEXT_SELECTOR = [
|
||||||
|
// 檔案列表(新版 React UI)
|
||||||
|
'.react-directory-filename-column',
|
||||||
|
'.react-directory-commit-message',
|
||||||
|
'[aria-labelledby="folders-and-files"] [role="rowheader"]',
|
||||||
|
// 最新 commit 列
|
||||||
|
'[data-testid="latest-commit-details"]',
|
||||||
|
'[data-testid="latest-commit"]',
|
||||||
|
// 任何指向檔案/資料夾/commit/raw 的連結(含裡頭包的 <strong> 等)
|
||||||
|
'a[href*="/blob/"]',
|
||||||
|
'a[href*="/tree/"]',
|
||||||
|
'a[href*="/commit/"]',
|
||||||
|
'a[href*="/raw/"]',
|
||||||
|
'a[href*="/releases/tag/"]',
|
||||||
|
// 倉庫描述(About 區塊的敘述段落)
|
||||||
|
'[data-testid="repository-description"]',
|
||||||
|
'[itemprop="about"]',
|
||||||
|
'.BorderGrid-cell > p',
|
||||||
|
'.f4.my-3',
|
||||||
|
// Topics 標籤
|
||||||
|
'a.topic-tag',
|
||||||
|
'.topic-tag',
|
||||||
|
'.topic-tag-link',
|
||||||
|
// 使用者/組織連結(含 hovercard)— 避免翻到使用者名稱
|
||||||
|
'a[data-hovercard-type="user"]',
|
||||||
|
'a[data-hovercard-type="organization"]',
|
||||||
|
'a[data-hovercard-url*="/users/"]',
|
||||||
|
'a[data-hovercard-url*="/orgs/"]',
|
||||||
|
'a[data-hovercard-url*="/hovercards"]',
|
||||||
|
// 搜尋框 / 過濾器(含語法高亮 token)
|
||||||
|
'[role="searchbox"]',
|
||||||
|
'[role="combobox"]',
|
||||||
|
'form[role="search"]',
|
||||||
|
'.subnav-search-input',
|
||||||
|
'.subnav-search-context',
|
||||||
|
'input[name="q"] ~ *',
|
||||||
|
'.pl-c1', '.pl-s', '.pl-en', '.pl-k', '.pl-v', '.pl-smi', '.pl-s1',
|
||||||
|
// Labels(使用者自定義 label 顏色塊)
|
||||||
|
'a.IssueLabel',
|
||||||
|
'.IssueLabel',
|
||||||
|
'.Label',
|
||||||
|
// 倉庫完整名 org/repo 連結
|
||||||
|
'a[data-octo-click="hovercard-link-click"]',
|
||||||
|
// README / 渲染後的 markdown
|
||||||
|
'.markdown-body',
|
||||||
|
'article.markdown-body',
|
||||||
|
// 檔案內容、程式碼檢視
|
||||||
|
'.blob-code-inner',
|
||||||
|
'.highlight',
|
||||||
|
'.react-code-lines',
|
||||||
|
'.react-blob-view-header-sticky',
|
||||||
|
// Issue / PR 標題與內文
|
||||||
|
'.markdown-title',
|
||||||
|
'.js-issue-title',
|
||||||
|
'.markdown-body .comment-body',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
// 預編譯一次:長詞優先、忽略大小寫
|
||||||
|
const WORD_BOUNDARY_REGEX = (() => {
|
||||||
|
const sorted = [...TRANSLATION_MAP.keys()]
|
||||||
|
.sort((a, b) => b.length - a.length)
|
||||||
|
.map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
|
return new RegExp(`\\b(${sorted.join('|')})\\b`, 'gi');
|
||||||
|
})();
|
||||||
|
|
||||||
|
// 給 regex 匹配結果做大小寫不敏感查表
|
||||||
|
const LOWER_MAP = new Map(
|
||||||
|
[...TRANSLATION_MAP.entries()].map(([k, v]) => [k.toLowerCase(), v])
|
||||||
|
);
|
||||||
|
|
||||||
|
// 把 62.8k → 6.3 萬、1.2M → 120 萬、999 以下千分號化
|
||||||
|
function formatAbbrev(_m, num, unit) {
|
||||||
|
const mul = unit.toLowerCase() === 'm' ? 1_000_000 : 1_000;
|
||||||
|
const val = Math.round(parseFloat(num) * mul);
|
||||||
|
if (val >= 10000) {
|
||||||
|
const wan = val / 10000;
|
||||||
|
const s = wan >= 100 ? Math.round(wan).toString() : wan.toFixed(1).replace(/\.0$/, '');
|
||||||
|
return `${s} 萬`;
|
||||||
|
}
|
||||||
|
return val.toLocaleString('en-US');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ABBREV_REGEX = /\b(\d+(?:\.\d+)?)([kKmM])\b/g;
|
||||||
|
|
||||||
|
function replaceText(text) {
|
||||||
|
if (!text) return text;
|
||||||
|
let out = text;
|
||||||
|
if (/[A-Za-z]/.test(out)) {
|
||||||
|
// 先處理 "X units ago" 避免被詞庫部分吃掉
|
||||||
|
out = out.replace(TIME_AGO_REGEX, (_m, n, u) => `${n} ${TIME_UNITS[u.toLowerCase()]}前`);
|
||||||
|
out = out.replace(TIME_AGO_ARTICLE_REGEX, (_m, _a, u) => `1 ${TIME_UNITS[u.toLowerCase()]}前`);
|
||||||
|
out = out.replace(WORD_BOUNDARY_REGEX, (m) => LOWER_MAP.get(m.toLowerCase()) ?? m);
|
||||||
|
}
|
||||||
|
if (ABBREV_REGEX.test(out)) {
|
||||||
|
ABBREV_REGEX.lastIndex = 0;
|
||||||
|
out = out.replace(ABBREV_REGEX, formatAbbrev);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipTextAncestor(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
if (el.closest?.(SKIP_CONTEXT_SELECTOR)) return true;
|
||||||
|
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
if (SKIP_TAGS.has(el.nodeName)) return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
if (el.getAttribute?.('role') === 'textbox') return true;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateAttributes(el) {
|
||||||
|
if (!(el instanceof Element)) return;
|
||||||
|
for (const attr of ATTRIBUTES_TO_TRANSLATE) {
|
||||||
|
const val = el.getAttribute?.(attr);
|
||||||
|
if (!val) continue;
|
||||||
|
const replaced = replaceText(val);
|
||||||
|
if (replaced !== val) el.setAttribute(attr, replaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateTextNode(node) {
|
||||||
|
const original = node.nodeValue;
|
||||||
|
if (!original || !/\S/.test(original)) return;
|
||||||
|
if (shouldSkipTextAncestor(node.parentElement)) return;
|
||||||
|
const replaced = replaceText(original);
|
||||||
|
if (replaced !== original) node.nodeValue = replaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateTree(root) {
|
||||||
|
if (!root) return;
|
||||||
|
// 屬性:root 自己 + 所有子孫
|
||||||
|
if (root instanceof Element && root.matches?.(ATTR_SELECTOR)) translateAttributes(root);
|
||||||
|
root.querySelectorAll?.(ATTR_SELECTOR).forEach(translateAttributes);
|
||||||
|
|
||||||
|
// 文字節點:用 TreeWalker 走全部
|
||||||
|
const scanRoot = root.nodeType === Node.DOCUMENT_NODE ? root.body : root;
|
||||||
|
if (!scanRoot) return;
|
||||||
|
const walker = document.createTreeWalker(scanRoot, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: (n) => {
|
||||||
|
if (!n.nodeValue || !/\S/.test(n.nodeValue)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (shouldSkipTextAncestor(n.parentElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const nodes = [];
|
||||||
|
let n;
|
||||||
|
while ((n = walker.nextNode())) nodes.push(n);
|
||||||
|
for (const node of nodes) translateTextNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
// debounce:短時間內多次變更只翻一次
|
||||||
|
function debounce(fn, wait) {
|
||||||
|
let timer = 0;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(fn, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
translateTree(document.body);
|
||||||
|
|
||||||
|
const scheduleTranslate = debounce(() => translateTree(document.body), 150);
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
if (m.type === 'attributes' && m.target instanceof Element) {
|
||||||
|
translateAttributes(m.target);
|
||||||
|
} else if (m.type === 'childList') {
|
||||||
|
m.addedNodes.forEach((n) => {
|
||||||
|
if (n.nodeType === Node.ELEMENT_NODE) translateTree(n);
|
||||||
|
else if (n.nodeType === Node.TEXT_NODE) translateTextNode(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 保險起見再安排一次全域掃描(SPA 會在微任務後才補資料)
|
||||||
|
scheduleTranslate();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
subtree: true,
|
||||||
|
childList: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ATTRIBUTES_TO_TRANSLATE,
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA 路由切換後重新掃描
|
||||||
|
const hookHistory = (type) => {
|
||||||
|
const orig = history[type];
|
||||||
|
history[type] = function () {
|
||||||
|
const ret = orig.apply(this, arguments);
|
||||||
|
scheduleTranslate();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
hookHistory('pushState');
|
||||||
|
hookHistory('replaceState');
|
||||||
|
window.addEventListener('popstate', scheduleTranslate);
|
||||||
|
document.addEventListener('turbo:load', scheduleTranslate);
|
||||||
|
document.addEventListener('turbo:render', scheduleTranslate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user