Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation.
От
// ==UserScript==
// @name Better GitHub Navigation
// @name:zh-CN 更好的 GitHub 导航栏
// @namespace https://github.com/ImXiangYu/better-github-nav
// @version 0.1.19
// @description Add quick access to Dashboard, Trending, Explore, Collections, and Stars from GitHub's top navigation.
// @description:zh-CN 在 GitHub 顶部导航中加入 Dashboard、Trending、Explore、Collections、Stars 快捷入口,常用页面一键直达。
// @author Ayubass
// @license MIT
// @match https://github.com/*
// @icon https://github.githubassets.com/pinned-octocat.svg
// @grant none
// ==/UserScript==
(function() {
'use strict';
const SCRIPT_VERSION = '0.1.19';
const CUSTOM_BUTTON_CLASS = 'custom-gh-nav-btn';
const CUSTOM_BUTTON_ACTIVE_CLASS = 'custom-gh-nav-btn-active';
const CUSTOM_BUTTON_COMPACT_CLASS = 'custom-gh-nav-btn-compact';
function normalizePath(href) {
try {
const url = new URL(href, location.origin);
const path = url.pathname.replace(/\/+$/, '');
return path || '/';
} catch (e) {
return '';
}
}
function isCurrentPage(linkPath) {
const currentPath = location.pathname.replace(/\/+$/, '') || '/';
if (linkPath === '/dashboard') return currentPath === '/' || currentPath === '/dashboard';
if (currentPath === linkPath) return true;
if (linkPath !== '/' && currentPath.startsWith(`${linkPath}/`)) return true;
// Stars 页面常见为 /<username>?tab=stars
return location.search.includes('tab=stars') && linkPath === normalizePath('/stars');
}
function ensureStyles() {
if (document.getElementById('custom-gh-nav-style')) return;
const style = document.createElement('style');
style.id = 'custom-gh-nav-style';
style.textContent = `
a.${CUSTOM_BUTTON_CLASS} {
border-radius: 6px;
padding-inline: 8px;
text-decoration: none;
}
a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_COMPACT_CLASS} {
padding-inline: 4px;
}
a.${CUSTOM_BUTTON_CLASS},
a.${CUSTOM_BUTTON_CLASS} span {
font-weight: 600;
}
a.${CUSTOM_BUTTON_CLASS},
a.${CUSTOM_BUTTON_CLASS} * {
cursor: pointer;
}
a.${CUSTOM_BUTTON_CLASS}:hover {
background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.12));
text-decoration: none;
}
a.${CUSTOM_BUTTON_CLASS}.${CUSTOM_BUTTON_ACTIVE_CLASS} {
background-color: var(--color-neutral-muted, rgba(177, 186, 196, 0.18));
font-weight: 600;
}
`;
document.head.appendChild(style);
}
function setActiveStyle(aTag, active, compact = false) {
aTag.classList.add(CUSTOM_BUTTON_CLASS);
if (compact) {
aTag.classList.add(CUSTOM_BUTTON_COMPACT_CLASS);
} else {
aTag.classList.remove(CUSTOM_BUTTON_COMPACT_CLASS);
}
if (active) {
aTag.setAttribute('aria-current', 'page');
aTag.classList.add(CUSTOM_BUTTON_ACTIVE_CLASS);
} else {
aTag.removeAttribute('aria-current');
aTag.classList.remove(CUSTOM_BUTTON_ACTIVE_CLASS);
}
}
function setLinkText(aTag, text) {
const innerSpan = aTag.querySelector('span');
if (innerSpan) {
innerSpan.textContent = text;
} else {
aTag.textContent = text;
}
}
function ensureAnchor(node, isLiParent) {
let aTag = isLiParent ? node.querySelector('a') : (node.tagName.toLowerCase() === 'a' ? node : node.querySelector('a'));
if (aTag) return aTag;
const fallbackText = (node.textContent || '').trim();
const fallbackHref = (!isLiParent && node.getAttribute && node.getAttribute('href'))
? node.getAttribute('href')
: `${location.pathname}${location.search}`;
const classSource = isLiParent
? node.querySelector('[class*="contextCrumb"], [class*="Breadcrumbs-Item"]')
: node;
const spanTemplate = document.querySelector(
'header a[class*="contextCrumb"] span[class*="contextCrumbLast"]'
);
const spanSource = isLiParent ? node.querySelector('span') : node.querySelector('span');
aTag = document.createElement('a');
if (classSource && classSource.className) {
aTag.className = classSource.className
.split(/\s+/)
.filter(cls => cls && !cls.includes('contextCrumbStatic'))
.join(' ');
}
if (spanSource && spanSource.className) {
const innerSpan = document.createElement('span');
innerSpan.className = spanTemplate && spanTemplate.className
? spanTemplate.className
: spanSource.className;
if (fallbackText) innerSpan.textContent = fallbackText;
aTag.appendChild(innerSpan);
}
if (!aTag.getAttribute('href') && fallbackHref) {
aTag.setAttribute('href', fallbackHref);
}
if (!aTag.textContent.trim() && fallbackText) {
const innerSpan = aTag.querySelector('span');
if (innerSpan) {
innerSpan.textContent = fallbackText;
} else {
aTag.textContent = fallbackText;
}
}
if (isLiParent) {
node.textContent = '';
node.appendChild(aTag);
} else {
node.replaceChildren(aTag);
}
return aTag;
}
function addCustomButtons() {
// 获取当前登录的用户名,用来动态生成 Stars 页面的专属链接
const userLoginMeta = document.querySelector('meta[name="user-login"]');
const username = userLoginMeta ? userLoginMeta.getAttribute('content') : '';
const starsUrl = username ? `/${username}?tab=stars` : '/stars';
// 固定导航顺序:Dashboard / Explore / Trending / Collections / Stars
const dashboardLink = { id: 'custom-gh-btn-dashboard', text: 'Dashboard', href: '/dashboard', path: '/dashboard' };
const customLinks = [
{ id: 'custom-gh-btn-explore', text: 'Explore', href: '/explore', path: '/explore' },
{ id: 'custom-gh-btn-trending', text: 'Trending', href: '/trending', path: '/trending' },
{ id: 'custom-gh-btn-collections', text: 'Collections', href: '/collections', path: '/collections' },
{ id: 'custom-gh-btn-stars', text: 'Stars', href: starsUrl, path: '/stars' }
];
const navPresetLinks = [dashboardLink, ...customLinks];
const fixedPages = new Set(['/dashboard', '/trending', '/explore', '/collections']);
const compactPages = new Set(['/issues', '/pulls', '/repositories']);
const isOnPresetPage = navPresetLinks.some(
link => fixedPages.has(link.path) && isCurrentPage(link.path)
);
const shouldUseCompactButtons = Array.from(compactPages).some(path => isCurrentPage(path));
// 预设页面优先主导航;其他页面优先 breadcrumb/context crumb 的最后一项(如仓库名)
let targetNode = null;
if (isOnPresetPage) {
targetNode = document.querySelector(
'header a[href="/dashboard"], header a[href="/trending"], header a[href="/explore"]'
);
} else {
const breadcrumbNodes = document.querySelectorAll(
'header nav[aria-label*="breadcrumb" i] a[href^="/"], ' +
'header a[class*="contextCrumb"][href^="/"], ' +
'header a[class*="Breadcrumbs-Item"][href^="/"]'
);
if (breadcrumbNodes.length) {
targetNode = breadcrumbNodes[breadcrumbNodes.length - 1];
}
}
// 全局导航中优先使用当前页项,避免误选最后一个导航按钮导致当前页无高亮
if (!targetNode) {
targetNode = document.querySelector(
'header nav a[aria-current="page"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[data-active="true"]:not([id^="custom-gh-btn-"]), ' +
'header nav [aria-current="page"]:not(a), ' +
'header nav [data-active="true"]:not(a)'
);
}
// 兼容兜底:若未找到主导航,再尝试旧规则
if (!targetNode) {
const navLinks = document.querySelectorAll('header a');
for (let link of navLinks) {
const text = link.textContent.trim().toLowerCase();
const href = link.getAttribute('href');
if (text === 'dashboard' || href === '/dashboard') {
targetNode = link;
break;
}
}
}
// 通用兜底:在有全局导航的页面(如 /pulls /issues /repositories)优先按当前路径匹配
if (!targetNode) {
const currentPath = location.pathname.replace(/\/+$/, '') || '/';
const globalNavCandidates = Array.from(
document.querySelectorAll(
'header nav[aria-label*="global" i] a[href^="/"], ' +
'header nav[aria-label*="header" i] a[href^="/"], ' +
'header nav a[href="/pulls"], ' +
'header nav a[href="/issues"], ' +
'header nav a[href="/repositories"], ' +
'header nav a[href="/codespaces"], ' +
'header nav a[href="/marketplace"], ' +
'header nav a[href="/explore"]'
)
).filter(link => {
const href = normalizePath(link.getAttribute('href') || '');
if (!href || href === '/') return false;
if (link.id && link.id.startsWith('custom-gh-btn-')) return false;
return true;
});
if (globalNavCandidates.length) {
targetNode = globalNavCandidates.find(link => {
const href = normalizePath(link.getAttribute('href') || '');
return href === currentPath;
}) || globalNavCandidates[globalNavCandidates.length - 1];
}
}
// 文本型当前项兜底:部分页面当前导航项是不可点击文本(非 a)
if (!targetNode) {
const currentTextNode = document.querySelector(
'header nav [aria-current="page"]:not(a), ' +
'header nav [data-active="true"]:not(a)'
);
if (currentTextNode) {
targetNode = currentTextNode;
}
}
// context crumb 文本项兜底:如 Issues/PRs 页为 span 而非 a
if (!targetNode) {
const contextCrumbTextNodes = document.querySelectorAll(
'header span[class*="contextCrumbStatic"], ' +
'header span[class*="contextCrumb"][class*="Breadcrumbs-Item"], ' +
'header .prc-Breadcrumbs-Item-jcraJ'
);
if (contextCrumbTextNodes.length) {
targetNode = contextCrumbTextNodes[contextCrumbTextNodes.length - 1];
}
}
// 样式模板优先使用同容器内可点击链接,避免从纯文本节点克隆导致样式不一致
let templateNode = targetNode;
if (targetNode) {
const localNav = targetNode.closest('nav, ul, ol');
const localAnchors = localNav
? localNav.querySelectorAll('a[href^="/"]:not([id^="custom-gh-btn-"])')
: [];
if (localAnchors.length) {
templateNode = localAnchors[localAnchors.length - 1];
} else {
const nativeNavAnchors = document.querySelectorAll(
'header nav[aria-label*="breadcrumb" i] a[href^="/"]:not([id^="custom-gh-btn-"]), ' +
'header a[class*="contextCrumb"][href^="/"]:not([id^="custom-gh-btn-"]), ' +
'header a[class*="Breadcrumbs-Item"][href^="/"]:not([id^="custom-gh-btn-"]), ' +
'header nav[aria-label*="global" i] a[href^="/"]:not([id^="custom-gh-btn-"]), ' +
'header nav[aria-label*="header" i] a[href^="/"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/pulls"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/issues"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/repositories"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/codespaces"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/marketplace"]:not([id^="custom-gh-btn-"]), ' +
'header nav a[href="/explore"]:not([id^="custom-gh-btn-"])'
);
if (nativeNavAnchors.length) {
templateNode = nativeNavAnchors[nativeNavAnchors.length - 1];
}
}
}
if (targetNode) {
// targetNode 用于决定插入位置,templateNode 用于克隆样式
const isTargetLiParent = targetNode.parentNode.tagName.toLowerCase() === 'li';
const insertAnchorNode = isTargetLiParent ? targetNode.parentNode : targetNode;
const isTemplateLiParent = templateNode.parentNode.tagName.toLowerCase() === 'li';
const cloneTemplateNode = isTemplateLiParent ? templateNode.parentNode : templateNode;
const targetHasAnchor = isTargetLiParent
? Boolean(insertAnchorNode.querySelector('a'))
: insertAnchorNode.tagName.toLowerCase() === 'a' || Boolean(insertAnchorNode.querySelector('a'));
const shouldForceCreateAnchor = !targetHasAnchor && Boolean(targetNode.closest('header nav'));
const anchorTag = (targetHasAnchor || shouldForceCreateAnchor)
? ensureAnchor(insertAnchorNode, isTargetLiParent)
: null;
const hasShortcutActive = navPresetLinks.some(link => isCurrentPage(link.path));
if (isOnPresetPage && anchorTag) {
// 五个预设页面:首个按钮固定为 Dashboard
anchorTag.id = dashboardLink.id;
anchorTag.href = dashboardLink.href;
setLinkText(anchorTag, dashboardLink.text);
setActiveStyle(anchorTag, isCurrentPage(dashboardLink.path), shouldUseCompactButtons);
} else {
// 其他页面:保留原生当前按钮,仅做高亮
if (anchorTag && anchorTag.id === dashboardLink.id) {
anchorTag.removeAttribute('id');
}
// 若快捷按钮已有命中(如 Stars 页),则避免双高亮
if (anchorTag) {
setActiveStyle(anchorTag, !hasShortcutActive, shouldUseCompactButtons);
}
}
// 设定插入的锚点,随着循环不断向后移动,保证按钮顺序正确
let insertAfterNode = insertAnchorNode;
const linksToRender = isOnPresetPage ? customLinks : navPresetLinks;
linksToRender.forEach(linkInfo => {
const existing = document.getElementById(linkInfo.id);
if (existing) {
existing.href = linkInfo.href;
setLinkText(existing, linkInfo.text);
setActiveStyle(existing, isCurrentPage(linkInfo.path), shouldUseCompactButtons);
return;
}
const newNode = cloneTemplateNode.cloneNode(true);
const aTag = ensureAnchor(newNode, isTemplateLiParent);
aTag.id = linkInfo.id;
aTag.href = linkInfo.href;
setLinkText(aTag, linkInfo.text);
setActiveStyle(aTag, isCurrentPage(linkInfo.path), shouldUseCompactButtons);
// 将新按钮插入到锚点之后,并更新锚点
insertAfterNode.parentNode.insertBefore(newNode, insertAfterNode.nextSibling);
insertAfterNode = newNode;
});
}
}
// 1. 页面初次加载时执行
console.info(`[Better GitHub Navigation] loaded v${SCRIPT_VERSION}`);
window.__betterGithubNavVersion = SCRIPT_VERSION;
ensureStyles();
addCustomButtons();
// 2. 监听 GitHub 的 Turbo/PJAX 页面跳转事件,防止切换页面后按钮消失
document.addEventListener('turbo:load', addCustomButtons);
document.addEventListener('pjax:end', addCustomButtons);
// 3. 终极备用方案:使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(() => {
if (!document.getElementById('custom-gh-btn-trending') && document.querySelector('header')) {
addCustomButtons();
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();