init: GitHub 中文化 (繁體) userscript v0.15
This commit is contained in:
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