Skip to content

Instantly share code, notes, and snippets.

@jjspace
Last active February 29, 2024 17:19
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jjspace/df4fc6eaa587f70e60985977205e0787 to your computer and use it in GitHub Desktop.
Save jjspace/df4fc6eaa587f70e60985977205e0787 to your computer and use it in GitHub Desktop.
Colorize repository and user names across various GitHub pages to make it easier to spot them and skim for matching names.
// ==UserScript==
// @name Colorcode GH
// @description Colorize repository and user names to allow for easier identification and skiming.
//
// @author jjspace
// @namespace https://github.com/jjspace
// @downloadURL https://gist.github.com/jjspace/df4fc6eaa587f70e60985977205e0787/raw/colorcode-gh.user.js
//
// @version 0.4.15
// @updateURL https://gist.github.com/jjspace/df4fc6eaa587f70e60985977205e0787/raw/colorcode-gh.user.js
//
// @match https://github.com/*
//
// @require https://raw.githubusercontent.com/bgrins/TinyColor/master/tinycolor.js
// ==/UserScript==
/**
* Changelog:
* see full code changes: https://gist.github.com/jjspace/df4fc6eaa587f70e60985977205e0787/revisions
*
* 0.4.15
* - removed an extra leftover console log
*
* 0.4.14
* - small fix/adjustment for `/` in issue names
*
* 0.4.13
* - Remove "magic colors" and consolidate into constants
* - Always use theme colors (previously used mix of dark AND dark_dimmed)
* - Debug option to ignore the new cache, sill WIP
* - Format with prettier (finally)
*
* 0.4.12
* - Add caching in local storage to even further improve performance and load times after
* a color has already been calculated
*
* 0.4.11
* - Add support for repo shorthands in expanded issue links that point to a different repo
*
* 0.4.10
* - Change global style to affect ALL links that have a color span inside to account for repo links
*
* 0.4.9
* - Add some global styles to avoid double underline on user links
* - Github added underlines to ALL links by default, more work will be needed to make this
* look good https://github.com/orgs/community/discussions/68734
*
* 0.4.8
* - Modify debounce to call immediately on the first call then debounce the rest.
* This is an attempt to reduce the "flash of uncolored page" delay
* - Filter the change list to ignore many changes in parts of the page that don't matter.
* This will probably be fine tuned more in the future
* - Add a check to not create a new page observer if it already exists. This should prevent
* SPA behavior from causing multiple observers as a precaution
* - Improved logging for when debugging/developing
*
* 0.4.7
* - Cache calculated color to avoid recomputing on larger pages, vastly increases performance
* - Re-add debounce function to avoid many many mutation calls when loading larger pages.
* This will add a very slight delay to styling on initial load but it doesn't feel intrusive
*
* 0.4.6
* - Remove debounce again. Still seeing the slow loading of pages (seems like GH's issue)
* AND this made the notification page colors flicker. Will have to revisit this solution.
*
* 0.4.5
* - Add debounce to color page call to reduce the number of updates on page load for large issues
*
* 0.4.4
* - highlight team mentions the same as user mentions
*
* 0.4.3
* - color repo names of links to issues or PRs in issue/pr comments
*
* 0.4.2
* - don't style the repo name on project boards, focus only on usernames
* - adjust spanify to use the .color-gh-repo class
* - fix detection on github.com/pulls and github.com/issues
*
* 0.4.1
* - Switched to a function per page approach
* - Adjusted for new SPA stuff github implemented
*
* 0.3.10
* - add colors to top level /pulls and /issues pages
*
* 0.3.9
* - fix update url
* - correctly color repo name when it matches org/user name
*
* Previous:
* I didn't start tracking before this, see the gist revisions
* https://gist.github.com/jjspace/df4fc6eaa587f70e60985977205e0787/revisions
*/
/*global tinycolor*/
// check out this color function if I don't want to include tinycolor anymore
// https://stackoverflow.com/questions/5560248/programmatically-lighten-or-darken-a-hex-color-or-rgb-and-blend-colors
(function () {
'use strict';
const DEBUG = false;
const IGNORE_STORAGE_CACHE = false;
const group = (...label) => {
if (DEBUG) console.group(...label);
};
const groupEnd = () => {
if (DEBUG) console.groupEnd();
};
const log = (...args) => {
if (DEBUG) console.log(...args);
};
const addStylesheet = (rules) => {
const styleEl = document.createElement('style');
styleEl.id = 'customSheet';
document.head.appendChild(styleEl);
const styleSheet = styleEl.sheet;
rules.forEach((rule) => {
const [selector, props] = rule;
let propStr = Object.entries(props).reduce((acc, [prop, val]) => {
return (
acc +
prop.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) +
`:${val};`
);
}, '');
styleSheet.insertRule(
`${selector}{${propStr}}`,
styleSheet.cssRules.length
);
});
return styleSheet;
};
// add some global styles for the page
function createStylesheet() {
addStylesheet([
// github added underlines to ALL links by default and it looks
// bad with the script changes
['a:has(.color-gh-repo)', { textDecoration: 'none !important' }],
]);
}
// only create styles if they dont exist
if (!document.querySelector('style#customSheet')) {
createStylesheet();
}
// used to create a hex color from a given string
// https://stackoverflow.com/questions/3426404/create-a-hexadecimal-colour-based-on-a-string-with-javascript
function hashCode(str) {
// java String#hashCode
var hash = 0;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}
function intToRGB(i) {
var c = (i & 0x00ffffff).toString(16).toUpperCase();
return '00000'.substring(0, 6 - c.length) + c;
}
function getCurrentTheme() {
const { dataset } = document.querySelector('html');
const isDarkMode =
dataset.colorMode === 'dark' ||
(dataset.colormode === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
return isDarkMode ? dataset.darkTheme : dataset.lightTheme;
}
const colorsPerTheme = {
light: {
canvasDefault: '#ffffff',
canvasSubtle: '#f6f8fa',
},
dark: {
canvasDefault: '#0d1117',
canvasSubtle: '#161b22',
},
dark_dimmed: {
canvasDefault: '#22272e',
canvasSubtle: '#2d333b',
},
};
const chosenTheme =
colorsPerTheme[getCurrentTheme] ?? colorsPerTheme.dark_dimmed;
const ThemeColors = {
pageBackground: chosenTheme.canvasDefault, // --color-canvas-default
notifReadBg: chosenTheme.canvasDefault, // --color-notificaitons-row-read-bg > --color-canvas-default
notifUnreadBg: chosenTheme.canvasSubtle, // --color-notifications-row-bg > --color-canvas-subtle
};
// store text to color map to reduce recalculations
let textToColorCache = new Map();
// store calculated readable colors in case 2 different inputs end up at the same color
const readableColorCache = new Set();
function localStorageKey() {
return `colorcode-gh-colors/${getCurrentTheme()}`;
}
function loadTextCache() {
if (IGNORE_STORAGE_CACHE) {
return;
}
textToColorCache = new Map(
JSON.parse(localStorage.getItem(localStorageKey()) ?? '[]')
);
log('load cache from localstorage', textToColorCache);
}
loadTextCache();
function saveTextCache() {
if (IGNORE_STORAGE_CACHE) {
return;
}
log('save cache to localstorage', textToColorCache);
localStorage.setItem(
localStorageKey(),
JSON.stringify(textToColorCache, (key, value) =>
value instanceof Map ? [...value] : value
)
);
}
// TODO: these bgColors probably shouldn't be passed in but consistent across ALL calls
// so that the same color is generated for every location of the same text regardless
function getReadableColorForText(text, bgColors) {
log('getReadableColorForText', text);
if (textToColorCache.has(text)) {
log(' in cache');
return textToColorCache.get(text);
}
// check if a color is readable on read and unread backgrounds
const isGoodColor = (color) => {
if (readableColorCache.has(color)) {
return true;
}
const guideline = { level: 'AA', size: 'small' };
const isGood = bgColors.reduce((acc, bgColor) => {
return acc && tinycolor.isReadable(color, bgColor, guideline);
}, true);
//log('isGood', isGood);
return isGood;
};
const color = intToRGB(hashCode(text));
let readableColor = tinycolor(color);
//group('readableText', text);
//log(readableColor.toHex());
let times = 0;
const maxTimes = 10;
const currentTheme = getCurrentTheme();
const isLightMode = currentTheme === 'light';
// lighten the color until it's readable or we've tried to lighten maxTimes
while (!isGoodColor(readableColor) && times < maxTimes) {
if (isLightMode) {
// TODO: I don't know the best way to adjust for light mode to give a pop of color
// while not getting too dark that it looks black. may need to also change the size of the line
// readableColor = readableColor.darken().saturate();
} else {
readableColor = readableColor.lighten().desaturate();
}
//log(readableColor.toHex());
times++;
}
//groupEnd();
const colorHex = readableColor.toHex();
readableColorCache.add(readableColor);
textToColorCache.set(text, colorHex);
return colorHex;
}
/**
* Add a border to the bottom of the given element
* color coded based on the text inside the element
* @param {HTMLElement} element the element to color
* @param {Array<string>} [bgColors] the background colors to ensure readability
* @param {object} [options={}]
* @param {boolean} [options.asTextDecoration=false] apply text decoration instead of a border, may look better depending on circumstances
*/
function colorElement(
element,
bgColors = ['#000'],
{ asTextDecoration = false } = {}
) {
if (!element) {
log('colorElement: element not found', element);
return;
}
let text = element.innerText;
const readableColor = getReadableColorForText(text, bgColors);
// Some elements aren't sized nicely for the border bottom style so
// utilize the text decoration instead
if (!asTextDecoration) {
element.style.borderBottom = `1px solid #${readableColor}`;
} else {
element.style.textDecoration = `underline #${readableColor}`;
// create a little extra spacing to make it easier to see the color highlight
// and match how it would look if it was the border method
element.style.textUnderlineOffset = '2px';
}
}
function updateOnMutate(
target,
callback,
ignoreMutation = (mutationTarget, addedNodes, removedNodes) => false
) {
if (window.colorizeObserver) {
log(
'%cObserver already set up, skipping creation',
'color: red; font-weight: bold;'
);
}
// call once before waiting for mutations
callback();
const mutationCallback = (mutationsList, observer) => {
group('mutation triggered');
for (const mutation of mutationsList) {
// only react to a change on the whole list
if (
ignoreMutation(
mutation.target,
mutation.addedNodes,
mutation.removedNodes
)
) {
log(
'mutation ignored',
mutation.target.tagName,
mutation.target.className
);
break;
}
if (mutation.type === 'childList') {
log('childlist mutation', mutation);
callback();
// the callback should only be called once per mutation set,
// it affects the whole page
break;
} else if (mutation.type === 'subtree') {
log('subtree mutation');
}
}
groupEnd();
};
const observer = new MutationObserver(mutationCallback);
observer.observe(target, { childList: true, subtree: true });
log('observer listening', target.className);
window.colorizeObserver = observer;
}
// TODO: create a generic spanify to enable colorizing any elements
// For example, more temporary keyword highlighting of notifs
function spanify(containerElem, searchTerm) {
if (containerElem.querySelectorAll('span[data-colorize]').length > 0) {
// TODO: add better detection as maybe we want different terms highlighted
// we've already spanified this elem
return;
}
containerElem.innerHTML = containerElem.innerHTML.replace(
searchTerm,
`<span class="color-gh-repo" data-colorize>${searchTerm}</span>`
);
return containerElem.querySelector('span.color-gh-repo');
}
function colorizeNotifPage() {
log('colorizeNotifPage called');
// access and extract the repo data
const sourceSelector = '.js-navigation-item [id^=notification] p.f6';
//const sourcePattern = /(?<user>\w+)\/(?<repo>[\w-]+) #(?<id>\d+)/; // old that includes id number
// username regex https://github.com/shinnn/github-username-regex
const sourcePattern =
/(?<user>[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\/(?<repo>[\w\-\.]+)/i;
document.querySelectorAll(sourceSelector).forEach((notifSource) => {
try {
const source = notifSource.innerText;
const match = source.match(sourcePattern);
if (!match) {
log('no user/repo combo found', source);
return;
}
const { user, repo, id } = match.groups;
// wrap repo name in span if it's not already
if (!notifSource.querySelector('span.color-gh-repo')) {
//notifSource.innerHTML = notifSource.innerHTML.replace(repo, `<span>${repo}</span>`);
// we need to replace the LAST occurance of the repo name in case the org and repo are the same
const lastIndex = notifSource.innerHTML.lastIndexOf(repo);
const replacement = `<span class="color-gh-repo">${repo}</span>`;
notifSource.innerHTML =
notifSource.innerHTML.substring(0, lastIndex) +
replacement +
notifSource.innerHTML.substring(lastIndex + repo.length + 1);
} else {
// if it was already put in a span,
// it should have already been colorized
return;
}
const repoSpan = notifSource.querySelector('span.color-gh-repo');
colorElement(
repoSpan,
[ThemeColors.notifReadBg, ThemeColors.notifUnreadBg],
{ asTextDecoration: true }
);
} catch (err) {
console.error('colorize error', err);
}
});
// access and extract sidebar repo list for a color key
const sidebarSelector =
'.js-notification-sidebar-repositories .filter-list a';
document.querySelectorAll(sidebarSelector).forEach((repoLink) => {
try {
const repoText = repoLink.innerText;
if (!repoText) {
log('no repotext', repoLink);
return;
}
const match = repoText.match(sourcePattern);
if (!match) {
log('no user/repo combo found', repoText);
return;
}
const { user, repo } = match.groups;
// wrap repo name in span if it's not already - There is already one span in here for the Number of notifs
if (!repoLink.querySelector('span.color-gh-repo')) {
// we need to replace the LAST occurance of the repo name in case the org and repo are the same
const lastIndex = repoLink.innerHTML.lastIndexOf(repo);
const replacement = `<span class="color-gh-repo">${repo}</span>`;
repoLink.innerHTML =
repoLink.innerHTML.substring(0, lastIndex) +
replacement +
repoLink.innerHTML.substring(lastIndex + repo.length + 1);
log(`set span around sidebar repo ${repo}`);
} else {
// if it was already put in a span,
// it should have already been colorized
return;
}
const repoSpan = repoLink.querySelector('span.color-gh-repo');
colorElement(
repoSpan,
[ThemeColors.notifReadBg, ThemeColors.notifUnreadBg],
{ asTextDecoration: true }
);
} catch (err) {
console.error('colorize error', err);
}
});
const notifTypeSelector = '.AvatarStack + span';
document.querySelectorAll(notifTypeSelector).forEach((notifTypeSpan) => {
colorElement(
notifTypeSpan,
[ThemeColors.notifReadBg, ThemeColors.notifUnreadBg],
{ asTextDecoration: true }
);
});
}
function colorizeCards() {
log('styling project board');
const cardUserSelector =
'article.issue-card .js-project-issue-details-container .js-issue-number ~ a:first-of-type';
document.querySelectorAll(cardUserSelector).forEach((elem) => {
colorElement(elem, [ThemeColors.notifUnreadBg]);
});
// these are the "Added by ..." wrapper cards
document.querySelectorAll('.mr-4 small a').forEach((elem) => {
colorElement(elem, [ThemeColors.notifUnreadBg]);
});
}
function colorizeRepoNames() {
log('styling pr/issues page');
const repoSelector = '[data-hovercard-type=repository]'; // <-- this makes it really easy but could break in the future
document.querySelectorAll(repoSelector).forEach((repoLink) => {
const repoText = repoLink.innerText;
const sourcePattern =
/(?<user>[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\/(?<repo>[\w\-\.]+)/i;
const { user, repo } = repoText.match(sourcePattern).groups;
// wrap repo name in span if it's not already - There is already one span in here for the Number of notifs
if (repoLink.querySelectorAll('span').length < 1) {
// we need to replace the LAST occurance of the repo name in case the org and repo are the same
const lastIndex = repoLink.innerHTML.lastIndexOf(repo);
const replacement = `<span class="color-gh-repo">${repo}</span>`;
repoLink.innerHTML =
repoLink.innerHTML.substring(0, lastIndex) +
replacement +
repoLink.innerHTML.substring(lastIndex + repo.length + 1);
log(`set span around sidebar repo ${repo}`);
} else {
// if it was already put in a span,
// it should have already been colorized
return;
}
const repoSpan = repoLink.querySelector('span.color-gh-repo');
colorElement(repoSpan, [
ThemeColors.notifReadBg,
ThemeColors.notifUnreadBg,
]);
});
}
function colorIssuesOrPulls() {
log('colorIssuesOrPulls called');
// recheck inside mutate handler for when page changes
const { pathname } = window.location;
if (pathname.includes('issues') || pathname.includes('pulls')) {
document.querySelectorAll('.opened-by a').forEach((elem) => {
colorElement(elem, [ThemeColors.notifReadBg]);
});
}
}
// colorize user mentions in issues/PRs
function colorIssuePRPage() {
// color user mentions in issue/pr comments and messages
document
.querySelectorAll(
':is(.timeline-comment, .review-comment) :is(.user-mention, .team-mention)'
)
.forEach((elem) => {
// strip off the `@` symbol
const userName = elem.innerText.substr(1);
spanify(elem, userName);
colorElement(
elem.querySelector('span[data-colorize]'),
[ThemeColors.pageBackground],
{ asTextDecoration: true }
);
});
// color repo names in links to other issues
// TODO: this will unintentionally pick up any issue that's expanded but has a `/` in the name like `remove/change`
document
.querySelectorAll(
// if there is an issue-shorthand the repo name is in there. Helps slightly solve the `/` in issue names
':is(.timeline-comment, .review-comment) .issue-link:not(:has(.issue-shorthand))'
)
.forEach((elem) => {
const sourcePattern =
/(?<user>[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38})\/(?<repo>[\w\-\.]+)/i;
const match = elem.innerText.match(sourcePattern);
if (match) {
// some repo links are just the #[number] format
const { repo } = match.groups;
const newSpan = spanify(elem, repo);
if (newSpan) {
colorElement(newSpan, [ThemeColors.pageBackground], {
asTextDecoration: true,
});
}
}
});
// Some issue links point to another repo with an extra muted text + #11 section
log('colorize shorthands');
const shorthandSelector = '.issue-shorthand';
document.querySelectorAll(shorthandSelector).forEach((shorthand) => {
const [repo] = shorthand.innerText.trim().split('#');
if (shorthand.querySelectorAll('span').length < 1) {
spanify(shorthand, repo);
log(`set span around shorthand ${repo}`);
} else {
// if it was already put in a span,
// it should have already been colorized
return;
}
const repoSpan = shorthand.querySelector('span.color-gh-repo');
colorElement(
repoSpan,
[ThemeColors.notifReadBg, ThemeColors.notifUnreadBg],
{ asTextDecoration: true }
);
});
}
function colorForPage() {
const { pathname } = window.location;
log(
'%ccolorForPage',
'color: blue; font-weight: bold;',
window.location.href
);
if (pathname.includes('notifications')) {
colorizeNotifPage();
} else if (pathname.includes('projects')) {
colorizeCards();
} else if (
pathname.startsWith('/pulls') ||
pathname.startsWith('/issues')
) {
colorizeRepoNames();
} else {
// if we're on any page
colorIssuesOrPulls();
if (document.querySelector('.notifications-list-item')) {
colorizeNotifPage();
}
colorIssuePRPage();
}
saveTextCache();
}
log('%c===== COLORIZER STARTED =====', 'color: green; font-weight: bold;');
function debounce(func, timeout = 300) {
let timer;
return (...args) => {
log('debounce called, timer:', timer);
if (!timer) {
log('no timer, call func');
func.apply(this, args);
timer = setTimeout(() => {
// set the timer so we ignore the first debounce call
// but start debouncing after the second one
log('timer reset', timer);
timer = undefined;
}, timeout);
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
log('debounce done', timer, ', call func');
func.apply(this, args);
timer = undefined;
}, timeout);
};
}
// attempt to avoid lagging page on change or after moving between many pages
const debouncedColorForPage = debounce(() => colorForPage(), 200);
// github does some SPA type history stuff on repo pages, to account
// for that this currently watches the whole page for changes which is
// much broader than I'd like but it seems the whole `body` element
// and everything inside is completely replaced on some page transitions
// but there's not a full "navigation" so the userscript isn't run again.
const observeTarget = document.querySelector('html');
updateOnMutate(
observeTarget,
debouncedColorForPage,
(target, addedNodes, removedNodes) => {
return (
target.tagName === 'HEAD' ||
target.tagName === 'HTML' ||
target.tagName === 'TOOL-TIP' ||
target.tagName === 'PROFILE-TIMEZONE' ||
target.tagName === 'TEXT-EXPANDER' ||
target.tagName === 'SLASH-COMMAND-EXPANDER' ||
target.classList.contains('sr-only') ||
target.classList.contains('Popover-message') ||
target.classList.contains('QueryBuilder-Sizer') ||
!!target.closest(
'.AppHeader, #partial-discussion-sidebar, #partial-new-comment-form-actions'
) ||
[...addedNodes].some((elem) => elem.className === 'color-gh-repo')
);
}
);
window.addEventListener('pushstate', (event) => {
log('%cpushstate', 'font-weight: bold', event);
debouncedColorForPage();
});
window.addEventListener('popstate', () => {
log('%cpopstate', 'font-weight: bold', event);
debouncedColorForPage();
});
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment