Last active
May 20, 2026 19:22
-
-
Save AHilyard/2687e9660d44cc21c84041d3fa687b98 to your computer and use it in GitHub Desktop.
Show whitespace on Github TamperMonkey script
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
| // ==UserScript== | |
| // @name GitHub Code Always Show Whitespace | |
| // @version 2.0.0 | |
| // @description Always shows spaces, tabs, non-breaking spaces, and line endings in GitHub code/diff views. | |
| // @license MIT | |
| // @author Rob Garrison, AHilyard | |
| // @match https://github.com/* | |
| // @match https://gist.github.com/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @icon https://github.githubassets.com/pinned-octocat.svg | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| const LINE_SELECTOR = [ | |
| ".blob-code-inner:not(.blob-code-hunk)", | |
| ".react-code-text:not(.react-line-number)", | |
| ".diff-text-inner" | |
| ].join(","); | |
| const WHITESPACE_REGEX = /[ \t\u00a0]/; | |
| const STYLE = ` | |
| html.ghcw-active .ghcw-whitespace { | |
| position: relative; | |
| display: inline; | |
| } | |
| html.ghcw-active .ghcw-whitespace::before { | |
| position: absolute; | |
| opacity: .5; | |
| user-select: none; | |
| pointer-events: none; | |
| font-weight: bold; | |
| color: #777 !important; | |
| top: -.25em; | |
| left: 0; | |
| } | |
| html.ghcw-active .pl-space::before { | |
| content: "\\b7"; | |
| } | |
| html.ghcw-active .pl-nbsp::before { | |
| content: "\\2423"; | |
| } | |
| html.ghcw-active .pl-tab::before { | |
| content: "\\bb"; | |
| } | |
| html.ghcw-active .pl-crlf { | |
| display: inline-block; | |
| width: 0; | |
| height: 1em; | |
| vertical-align: baseline; | |
| } | |
| html.ghcw-active .pl-crlf::before { | |
| content: "\\21b5"; | |
| top: .1em; | |
| left: 0; | |
| } | |
| .blob-code-inner > br, | |
| .react-code-text > br, | |
| .diff-text-inner > br { | |
| display: none !important; | |
| } | |
| `; | |
| let queued = false; | |
| let queue = []; | |
| function addStyle() { | |
| const style = document.createElement("style"); | |
| style.textContent = STYLE; | |
| document.documentElement.appendChild(style); | |
| document.documentElement.classList.add("ghcw-active"); | |
| } | |
| function createWhitespaceSpan(character) { | |
| const span = document.createElement("span"); | |
| span.classList.add("ghcw-whitespace"); | |
| if (character === " ") { | |
| span.classList.add("pl-space"); | |
| span.textContent = " "; | |
| } else if (character === "\t") { | |
| span.classList.add("pl-tab"); | |
| span.textContent = "\t"; | |
| } else { | |
| span.classList.add("pl-nbsp"); | |
| span.textContent = "\u00a0"; | |
| } | |
| return span; | |
| } | |
| function createLineEndingSpan() { | |
| const span = document.createElement("span"); | |
| span.className = "pl-crlf ghcw-whitespace"; | |
| span.textContent = "\u200b"; | |
| return span; | |
| } | |
| function shouldSkipTextNode(node) { | |
| if (!node.nodeValue || !WHITESPACE_REGEX.test(node.nodeValue)) { | |
| return true; | |
| } | |
| const parent = node.parentElement; | |
| return Boolean( | |
| parent && | |
| parent.closest(".ghcw-whitespace, textarea, input, script, style") | |
| ); | |
| } | |
| function removeRenderedLineBreaks(node) { | |
| const walker = document.createTreeWalker( | |
| node, | |
| NodeFilter.SHOW_TEXT, | |
| null | |
| ); | |
| const textNodes = []; | |
| let currentNode; | |
| while ((currentNode = walker.nextNode())) { | |
| textNodes.push(currentNode); | |
| } | |
| for (const textNode of textNodes) { | |
| if (textNode.parentElement && textNode.parentElement.closest(".ghcw-whitespace")) { | |
| continue; | |
| } | |
| if (textNode.nodeValue.indexOf("\n") !== -1 || textNode.nodeValue.indexOf("\r") !== -1) { | |
| textNode.nodeValue = textNode.nodeValue.replace(/[\r\n]+/g, ""); | |
| } | |
| } | |
| for (const br of node.querySelectorAll(":scope > br")) { | |
| br.remove(); | |
| } | |
| } | |
| function replaceTextNode(node) { | |
| const fragment = document.createDocumentFragment(); | |
| const text = node.nodeValue; | |
| for (let index = 0; index < text.length; index++) { | |
| const character = text[index]; | |
| if (character === " " || character === "\t" || character === "\u00a0") { | |
| fragment.appendChild(createWhitespaceSpan(character)); | |
| } else { | |
| fragment.appendChild(document.createTextNode(character)); | |
| } | |
| } | |
| node.parentNode.replaceChild(fragment, node); | |
| } | |
| function processLine(line) { | |
| if (!line || !line.isConnected) { | |
| return; | |
| } | |
| removeRenderedLineBreaks(line); | |
| const textNodes = []; | |
| const walker = document.createTreeWalker( | |
| line, | |
| NodeFilter.SHOW_TEXT, | |
| { | |
| acceptNode(node) { | |
| return shouldSkipTextNode(node) | |
| ? NodeFilter.FILTER_REJECT | |
| : NodeFilter.FILTER_ACCEPT; | |
| } | |
| } | |
| ); | |
| let node; | |
| while ((node = walker.nextNode())) { | |
| textNodes.push(node); | |
| } | |
| for (const textNode of textNodes) { | |
| replaceTextNode(textNode); | |
| } | |
| if (!line.querySelector(".pl-crlf.ghcw-whitespace")) { | |
| line.appendChild(createLineEndingSpan()); | |
| } | |
| } | |
| function enqueueLines(root) { | |
| if (!root || root.nodeType !== Node.ELEMENT_NODE) { | |
| return; | |
| } | |
| if (root.matches(LINE_SELECTOR)) { | |
| queue.push(root); | |
| } | |
| queue.push(...root.querySelectorAll(LINE_SELECTOR)); | |
| if (!queued) { | |
| queued = true; | |
| requestAnimationFrame(processQueue); | |
| } | |
| } | |
| function processQueue() { | |
| queued = false; | |
| const lines = [...new Set(queue)]; | |
| queue = []; | |
| let index = 0; | |
| function chunk() { | |
| const end = Math.min(index + 60, lines.length); | |
| for (; index < end; index++) { | |
| processLine(lines[index]); | |
| } | |
| if (index < lines.length) { | |
| requestAnimationFrame(chunk); | |
| } | |
| } | |
| chunk(); | |
| } | |
| function observePage() { | |
| const observer = new MutationObserver(mutations => { | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| enqueueLines(node); | |
| } | |
| } | |
| }); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| function start() { | |
| addStyle(); | |
| enqueueLines(document.body); | |
| observePage(); | |
| } | |
| if (document.body) { | |
| start(); | |
| } else { | |
| document.addEventListener("DOMContentLoaded", start, { once: true }); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment