Last active
July 27, 2026 10:25
-
-
Save vikytech/cba4c758c674d1896cff4d1ac08e716a to your computer and use it in GitHub Desktop.
Script to display the Name instead of the username across github (PR/Comments/Commits/Branch/Action etc).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (() => { | |
| 'use strict'; | |
| /* | |
| * Display modes: | |
| * "name-only" → John Smith | |
| * "name-and-username" → John Smith (@AA47797) | |
| */ | |
| const DISPLAY_MODE = 'name-only'; | |
| const CACHE_PREFIX = 'github-display-name:'; | |
| const CACHE_DURATION_MS = 30 * 24 * 60 * 60 * 1000; | |
| const pendingRequests = new Map(); | |
| const RESERVED_PATHS = new Set([ | |
| 'actions', | |
| 'admin', | |
| 'apps', | |
| 'codespaces', | |
| 'dashboard', | |
| 'enterprises', | |
| 'explore', | |
| 'features', | |
| 'issues', | |
| 'login', | |
| 'logout', | |
| 'marketplace', | |
| 'new', | |
| 'notifications', | |
| 'organizations', | |
| 'orgs', | |
| 'pulls', | |
| 'search', | |
| 'security', | |
| 'settings', | |
| 'site', | |
| 'sponsors', | |
| 'topics', | |
| ]); | |
| function normalizeLogin(value) { | |
| return String(value || '') | |
| .trim() | |
| .replace(/^@/, '') | |
| .replace(/[()[\]{},:;.!?]+$/g, ''); | |
| } | |
| function isValidLogin(login) { | |
| return ( | |
| typeof login === 'string' && | |
| /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i.test(login) && | |
| !RESERVED_PATHS.has(login.toLowerCase()) | |
| ); | |
| } | |
| function extractLoginFromHovercard(value) { | |
| if (!value) { | |
| return null; | |
| } | |
| const match = value.match(/\/users\/([^/?#]+)\/hovercard/i); | |
| if (!match) { | |
| return null; | |
| } | |
| try { | |
| const login = decodeURIComponent(match[1]); | |
| return isValidLogin(login) ? login : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function getLoginFromElement(element) { | |
| if (!(element instanceof Element)) { | |
| return null; | |
| } | |
| const directValues = [ | |
| element.getAttribute('data-login'), | |
| element.getAttribute('data-user-login'), | |
| element.getAttribute('data-username'), | |
| ]; | |
| for (const value of directValues) { | |
| const login = normalizeLogin(value); | |
| if (isValidLogin(login)) { | |
| return login; | |
| } | |
| } | |
| const ownHovercardLogin = extractLoginFromHovercard( | |
| element.getAttribute('data-hovercard-url') | |
| ); | |
| if (ownHovercardLogin) { | |
| return ownHovercardLogin; | |
| } | |
| if (element instanceof HTMLAnchorElement) { | |
| try { | |
| const url = new URL(element.href, location.origin); | |
| if (url.origin !== location.origin) { | |
| return null; | |
| } | |
| const author = normalizeLogin(url.searchParams.get('author')); | |
| if (isValidLogin(author)) { | |
| return author; | |
| } | |
| const segments = url.pathname.split('/').filter(Boolean); | |
| if (segments.length === 1) { | |
| const login = decodeURIComponent(segments[0]); | |
| if (isValidLogin(login)) { | |
| return login; | |
| } | |
| } | |
| } catch { | |
| return null; | |
| } | |
| } | |
| return null; | |
| } | |
| function getCachedName(login) { | |
| try { | |
| const key = CACHE_PREFIX + login.toLowerCase(); | |
| const raw = localStorage.getItem(key); | |
| if (!raw) { | |
| return undefined; | |
| } | |
| const cached = JSON.parse(raw); | |
| if ( | |
| !cached || | |
| typeof cached.savedAt !== 'number' || | |
| Date.now() - cached.savedAt > CACHE_DURATION_MS | |
| ) { | |
| localStorage.removeItem(key); | |
| return undefined; | |
| } | |
| return typeof cached.name === 'string' ? cached.name : ''; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| function cacheName(login, name) { | |
| try { | |
| localStorage.setItem( | |
| CACHE_PREFIX + login.toLowerCase(), | |
| JSON.stringify({ | |
| name, | |
| savedAt: Date.now(), | |
| }) | |
| ); | |
| } catch { | |
| // Continue without caching. | |
| } | |
| } | |
| async function fetchDisplayName(login) { | |
| const cachedName = getCachedName(login); | |
| if (cachedName !== undefined) { | |
| return cachedName; | |
| } | |
| const key = login.toLowerCase(); | |
| if (pendingRequests.has(key)) { | |
| return pendingRequests.get(key); | |
| } | |
| const request = (async () => { | |
| try { | |
| const response = await fetch(`/${encodeURIComponent(login)}`, { | |
| method: 'GET', | |
| credentials: 'same-origin', | |
| headers: { | |
| Accept: 'text/html', | |
| }, | |
| }); | |
| if (!response.ok) { | |
| console.debug( | |
| `[GitHub Names] Profile request failed for ${login}:`, | |
| response.status | |
| ); | |
| return ''; | |
| } | |
| const html = await response.text(); | |
| const profileDocument = new DOMParser().parseFromString( | |
| html, | |
| 'text/html' | |
| ); | |
| const selectors = [ | |
| '[itemprop="name"]', | |
| '.vcard-fullname', | |
| '[data-testid="profile-name"]', | |
| '.js-profile-editable-area .p-name', | |
| ]; | |
| let displayName = ''; | |
| for (const selector of selectors) { | |
| const value = profileDocument | |
| .querySelector(selector) | |
| ?.textContent?.trim(); | |
| if (value) { | |
| displayName = value; | |
| break; | |
| } | |
| } | |
| cacheName(login, displayName); | |
| return displayName; | |
| } catch (error) { | |
| console.debug( | |
| `[GitHub Names] Could not load profile for ${login}:`, | |
| error | |
| ); | |
| return ''; | |
| } finally { | |
| pendingRequests.delete(key); | |
| } | |
| })(); | |
| pendingRequests.set(key, request); | |
| return request; | |
| } | |
| function formatDisplayName(name, login) { | |
| return DISPLAY_MODE === 'name-and-username' ? `${name} (@${login})` : name; | |
| } | |
| function getUserColours(login) { | |
| let hash = 0; | |
| for (let index = 0; index < login.length; index++) { | |
| hash = login.charCodeAt(index) + ((hash << 5) - hash); | |
| hash |= 0; | |
| } | |
| const hue = Math.abs(hash) % 360; | |
| return { | |
| background: `hsl(${hue}, 72%, 86%)`, | |
| border: `hsl(${hue}, 52%, 68%)`, | |
| text: `hsl(${hue}, 55%, 20%)`, | |
| }; | |
| } | |
| function applyUserStyle(element, login) { | |
| const colours = getUserColours(login); | |
| element.style.setProperty( | |
| 'background-color', | |
| colours.background, | |
| 'important' | |
| ); | |
| element.style.setProperty('color', colours.text, 'important'); | |
| element.style.setProperty( | |
| 'border', | |
| `1px solid ${colours.border}`, | |
| 'important' | |
| ); | |
| element.style.setProperty('border-radius', '999px', 'important'); | |
| element.style.setProperty('padding', '1px 6px', 'important'); | |
| element.style.setProperty('margin', '1px 2px', 'important'); | |
| element.style.setProperty('font-weight', '600', 'important'); | |
| element.style.setProperty('line-height', '1', 'important'); | |
| element.style.setProperty('display', 'inline-block', 'important'); | |
| element.style.setProperty('text-decoration', 'none', 'important'); | |
| element.style.setProperty('white-space', 'nowrap', 'important'); | |
| } | |
| function visibleTextMatchesLogin(element, login) { | |
| const text = normalizeLogin(element.textContent); | |
| return text.toLowerCase() === login.toLowerCase(); | |
| } | |
| function isSuitableTarget(element, login) { | |
| if (!(element instanceof Element)) { | |
| return false; | |
| } | |
| if (element.dataset.githubDisplayNameApplied === 'true') { | |
| return false; | |
| } | |
| /* | |
| * Only modify elements whose visible content is the username. | |
| * This prevents replacing a parent containing both name and username. | |
| */ | |
| return visibleTextMatchesLogin(element, login); | |
| } | |
| async function processElement(element) { | |
| if (!(element instanceof Element)) { | |
| return; | |
| } | |
| if (element.dataset.githubDisplayNameApplied === 'true') { | |
| return; | |
| } | |
| const login = getLoginFromElement(element); | |
| if (!login || !isSuitableTarget(element, login)) { | |
| return; | |
| } | |
| /* | |
| * Mark immediately so MutationObserver scans cannot process this | |
| * element again while the profile request is still loading. | |
| */ | |
| element.dataset.githubDisplayNameApplied = 'loading'; | |
| element.dataset.originalGithubLogin = login; | |
| const originalText = element.textContent; | |
| const displayName = await fetchDisplayName(login); | |
| if (!element.isConnected) { | |
| return; | |
| } | |
| if (!displayName) { | |
| delete element.dataset.githubDisplayNameApplied; | |
| return; | |
| } | |
| if ( | |
| element.textContent !== originalText && | |
| normalizeLogin(element.textContent).toLowerCase() !== login.toLowerCase() | |
| ) { | |
| delete element.dataset.githubDisplayNameApplied; | |
| return; | |
| } | |
| element.textContent = formatDisplayName(displayName, login); | |
| element.dataset.githubDisplayNameApplied = 'true'; | |
| applyUserStyle(element, login); | |
| element.setAttribute( | |
| 'title', | |
| `${displayName} — GitHub username: @${login}` | |
| ); | |
| } | |
| function scan(root = document) { | |
| if (!(root instanceof Document || root instanceof Element)) { | |
| return; | |
| } | |
| /* | |
| * More specific elements come first. | |
| * We do not process parent hovercard containers separately. | |
| */ | |
| const selector = [ | |
| "a[data-hovercard-url*='/users/']", | |
| "a[data-hovercard-type='user']", | |
| 'a[data-login]', | |
| 'a[data-user-login]', | |
| 'a[data-username]', | |
| "a[href*='?author=']", | |
| "a[href*='&author=']", | |
| "a[href^='/']", | |
| ].join(','); | |
| if (root instanceof Element && root.matches(selector)) { | |
| void processElement(root); | |
| } | |
| const elements = root.querySelectorAll?.(selector) || []; | |
| for (const element of elements) { | |
| void processElement(element); | |
| } | |
| } | |
| let scanQueued = false; | |
| function queueScan() { | |
| if (scanQueued) { | |
| return; | |
| } | |
| scanQueued = true; | |
| requestAnimationFrame(() => { | |
| scanQueued = false; | |
| scan(document); | |
| }); | |
| } | |
| const observer = new MutationObserver((mutations) => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (node instanceof Element) { | |
| scan(node); | |
| } | |
| } | |
| } | |
| }); | |
| observer.observe(document.documentElement, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| document.addEventListener('turbo:load', queueScan); | |
| document.addEventListener('turbo:render', queueScan); | |
| document.addEventListener('pjax:end', queueScan); | |
| /* | |
| * Supports GitHub components rendered after initial navigation. | |
| */ | |
| setInterval(queueScan, 3000); | |
| scan(); | |
| console.log('[GitHub Names] Name-only display enabled without duplicates.'); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment