commit 9ea401ac1da3c430a1687f8f1df1c0c830423288 Author: Timmy Date: Wed Apr 22 17:00:35 2026 +0800 init: GitHub 中文化 (繁體) userscript v0.15 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..a84ff97 --- /dev/null +++ b/DEVELOPMENT.md @@ -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` +- 純數字不動 + +**額外問題:** `62.8k` 沒被掃到。 +**原因:** SCAN_SELECTORS 沒包 ``。 +**修:** 加入 `strong, b, em, h1-h4, li`。 + +### v0.10 +**Bug:** 按鈕上的 `7.8k` / `62.8k`(在 `` 裡)沒格式化。 +**原因:** `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:** `` / `` / `` 的文字沒翻,`` 的 placeholder 也沒翻。 +**原因:** SCAN_SELECTORS 白名單太窄;`` 整個被 SKIP_TAGS 跳過,連屬性都沒碰。 + +**大改:** +1. 拿掉 SCAN_SELECTORS,改用 `TreeWalker` 走所有文字節點 +2. 屬性翻譯獨立於文字節點跳過邏輯 — `` 文字跳過但 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` 全文。或用「從本地檔案匯入」。 diff --git a/github-zh-tw.user.js b/github-zh-tw.user.js new file mode 100644 index 0000000..322d726 --- /dev/null +++ b/github-zh-tw.user.js @@ -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 的連結(含裡頭包的 等) + '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(); + } +})();