包在 .hide-sm.hide-md 裡,原 `> p` 直接子代選擇器失效。 // 用 p.f4 靠語意(f4 = 大型段落文字 = prose 描述),對未來 tmp-* 類別變動有抵抗力。 'p.f4', // 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', // 2026 新版 React SearchBar:filter 語法 token 用 data-type 標記(is / state / open / issue 等), // 外層 form 只有 role="form" 不是 role="search",原規則抓不到。 '[data-type="filter-expression"]', '[data-type="filter"]', '[data-type="filter-value"]', '.styled-input-container', // 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(); } })();