init: GitHub 中文化 (繁體) userscript v0.15

This commit is contained in:
2026-04-22 17:00:35 +08:00
commit 9ea401ac1d
2 changed files with 629 additions and 0 deletions

471
github-zh-tw.user.js Normal file
View 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"]',
// 任何指向檔案資料夾commitraw 的連結(含裡頭包的 <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();
}
})();