// ==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(); } })();