Skip to content

Instantly share code, notes, and snippets.

@ferblape
Last active September 14, 2026 09:08
Show Gist options
  • Select an option

  • Save ferblape/a82456543cdd090e8a00b8a57e184793 to your computer and use it in GitHub Desktop.

Select an option

Save ferblape/a82456543cdd090e8a00b8a57e184793 to your computer and use it in GitHub Desktop.
GitHub PR Markdown Preview — userscript that renders markdown files of a pull request at full browser width, highlighting the blocks the PR changed
// ==UserScript==
// @name GitHub PR Markdown Preview
// @namespace https://github.com/ferblape
// @version 1.1.2
// @description Adds a button on every markdown file of a pull request diff that opens the head version rendered at full browser width, highlighting the blocks the pull request changed
// @author Fernando Blat
// @match https://github.com/*/*/pull/*
// @run-at document-idle
// @grant GM_addStyle
// @downloadURL https://gist.githubusercontent.com/ferblape/a82456543cdd090e8a00b8a57e184793/raw/github-pr-markdown-preview.user.js
// @updateURL https://gist.githubusercontent.com/ferblape/a82456543cdd090e8a00b8a57e184793/raw/github-pr-markdown-preview.user.js
// @require https://cdn.jsdelivr.net/npm/marked@18.0.13/lib/marked.umd.js#sha384=Jy8qDMspJASzATgFngF2ompIKy0StbCcvuTE65mxDm/E0/YSIF6Ndc+5V7bbwRcw
// @require https://cdn.jsdelivr.net/npm/dompurify@3.4.15/dist/purify.min.js#sha384=uUMu9JDY09vBzRf9SPcK2VgUj+W/70J6Soc+Dded5P474ElQ63iv9j5N3DE7Kp3N
// ==/UserScript==
(function () {
'use strict';
document.documentElement.dataset.ghmdpVersion = '1.1.2';
const OVERLAY_ID = 'ghmdp-overlay';
const MARKDOWN_FILE = /\.(md|markdown|mdx)$/i;
const DIRECTIONALITY_MARKS = /[‎‏]/g;
const ANCHOR_PREFIX = '#diff-';
const BOOK_ICON = 'M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z';
const CLOSE_ICON = 'M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z';
GM_addStyle(`
.ghmdp-button {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; margin-left: 2px; padding: 0;
border: 0; border-radius: 6px; background: transparent;
color: var(--fgColor-muted, #59636e); cursor: pointer;
}
.ghmdp-button:hover {
background: var(--bgColor-neutral-muted, rgba(129, 139, 152, 0.12));
color: var(--fgColor-default, #1f2328);
}
#${OVERLAY_ID} {
position: fixed; inset: 0; z-index: 2147483000;
display: flex; flex-direction: column;
background: var(--bgColor-default, #ffffff);
color: var(--fgColor-default, #1f2328);
}
#${OVERLAY_ID} .ghmdp-bar {
flex: 0 0 auto; display: flex; align-items: center; gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid var(--borderColor-default, #d1d9e0);
font-family: var(--fontStack-monospace, ui-monospace, monospace);
font-size: 12px;
}
#${OVERLAY_ID} .ghmdp-path { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#${OVERLAY_ID} .ghmdp-hint { flex: 0 0 auto; color: var(--fgColor-muted, #59636e); }
#${OVERLAY_ID} .ghmdp-scroll { flex: 1 1 auto; overflow: auto; padding: 24px 48px; }
#${OVERLAY_ID} .markdown-body { max-width: none; background: transparent; }
#${OVERLAY_ID} .ghmdp-changed {
margin-left: -16px; padding-left: 13px;
border-left: 3px solid var(--borderColor-success-emphasis, #1a7f37);
background: var(--bgColor-success-muted, #dafbe1);
}
#${OVERLAY_ID} .ghmdp-changed > :last-child { margin-bottom: 0; }
#${OVERLAY_ID} .ghmdp-changed + .ghmdp-changed { margin-top: -16px; padding-top: 16px; }
#${OVERLAY_ID} .ghmdp-count { flex: 0 0 auto; color: var(--fgColor-muted, #59636e); }
`);
function pullRequestLocation() {
const match = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
return match ? { owner: match[1], repo: match[2], number: Number(match[3]) } : null;
}
function indexFromDocument(documentToRead, pullRequest) {
const scripts = documentToRead.querySelectorAll('script[type="application/json"][data-target="react-app.embeddedData"]');
for (const script of scripts) {
if (!script.textContent.includes('pullRequestsChangesRoute')) continue;
let payload;
try {
payload = JSON.parse(script.textContent).payload;
} catch (error) {
return null;
}
const changes = payload && payload.pullRequestsChangesRoute;
const layout = payload && payload.pullRequestsLayoutRoute;
if (!changes || !layout || layout.pullRequest.number !== pullRequest.number) return null;
const headSha = (changes.comparison && changes.comparison.headOid) || layout.pullRequest.headSha;
if (!headSha) return null;
const summaries = new Map((changes.diffSummaries || []).map((summary) => [summary.pathDigest, summary]));
return { headSha, summaries };
}
return null;
}
let cachedIndexKey = null;
let cachedIndex = null;
async function diffIndex(pullRequest) {
const key = `${pullRequest.owner}/${pullRequest.repo}#${pullRequest.number}`;
if (cachedIndexKey === key && cachedIndex) return cachedIndex;
const fromPage = indexFromDocument(document, pullRequest);
if (fromPage) {
cachedIndexKey = key;
cachedIndex = Promise.resolve(fromPage);
return fromPage;
}
if (cachedIndexKey !== key) {
cachedIndexKey = key;
cachedIndex = fetchIndex(pullRequest);
}
return cachedIndex;
}
async function fetchIndex(pullRequest) {
const response = await fetch(`/${pullRequest.owner}/${pullRequest.repo}/pull/${pullRequest.number}/changes`);
if (!response.ok) return null;
const html = await response.text();
return indexFromDocument(new DOMParser().parseFromString(html, 'text/html'), pullRequest);
}
let cachedDiffKey = null;
let cachedDiff = null;
function changedLinesByPath(pullRequest) {
const key = `${pullRequest.owner}/${pullRequest.repo}#${pullRequest.number}`;
if (cachedDiffKey !== key) {
cachedDiffKey = key;
cachedDiff = fetchChangedLines(pullRequest).catch(() => new Map());
}
return cachedDiff;
}
async function fetchChangedLines(pullRequest) {
const response = await fetch(`/${pullRequest.owner}/${pullRequest.repo}/pull/${pullRequest.number}.diff`);
if (!response.ok) return new Map();
return parseUnifiedDiff(await response.text());
}
function parseUnifiedDiff(diff) {
const byPath = new Map();
let changed = null;
let headLine = 0;
let previous = '';
for (const line of diff.split('\n')) {
if (line.startsWith('+++ ') && previous.startsWith('--- ')) {
const path = line.slice(4).replace(/\t.*$/, '').replace(/^b\//, '');
changed = path === '/dev/null' ? null : new Set();
if (changed) byPath.set(path, changed);
headLine = 0;
} else if (line.startsWith('@@')) {
const hunk = line.match(/@@ -\d+(?:,\d+)? \+(\d+)/);
headLine = hunk ? Number(hunk[1]) : 0;
} else if (changed && headLine) {
if (line.startsWith('+')) changed.add(headLine++);
else if (line.startsWith(' ')) headLine++;
else if (!line.startsWith('-') && !line.startsWith('\\')) headLine = 0;
}
previous = line;
}
return byPath;
}
function renderBlocks(source, changed, firstLine) {
const tokens = marked.lexer(source);
let line = firstLine;
let changedBlocks = 0;
const html = [];
for (const token of tokens) {
const newlines = (token.raw.match(/\n/g) || []).length;
const start = line;
const end = token.raw.endsWith('\n') ? line + newlines - 1 : line + newlines;
line += newlines;
if (token.type === 'space') continue;
const block = marked.parser([token]);
if (!block.trim()) continue;
if (!changed || !intersects(changed, start, end)) {
html.push(block);
continue;
}
changedBlocks += 1;
html.push(`<div class="ghmdp-changed">${block}</div>`);
}
return { html: html.join(''), changedBlocks };
}
function intersects(changed, start, end) {
for (let line = start; line <= end; line += 1) if (changed.has(line)) return true;
return false;
}
function summaryFor(header, index) {
const anchor = header.querySelector(`a[href^="${ANCHOR_PREFIX}"]`);
const digest = anchor && anchor.getAttribute('href').slice(ANCHOR_PREFIX.length);
const summary = digest && index.summaries.get(digest);
if (summary) return summary;
const heading = header.querySelector('h3');
if (!heading) return null;
return { path: heading.textContent.replace(DIRECTIONALITY_MARKS, '').trim() };
}
function icon(path, size) {
return `<svg aria-hidden="true" viewBox="0 0 16 16" width="${size}" height="${size}" fill="currentColor"><path d="${path}"></path></svg>`;
}
function addPreviewButton(header, path, headSha) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'ghmdp-button';
button.title = `Preview ${path} rendered`;
button.setAttribute('aria-label', `Preview ${path} rendered`);
button.innerHTML = icon(BOOK_ICON, 16);
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
openPreview(path, headSha);
});
const copyIcon = header.querySelector('svg.octicon-copy');
const copyButton = copyIcon && copyIcon.closest('button');
const heading = header.querySelector('h3');
if (copyButton) copyButton.after(button);
else if (heading) heading.after(button);
}
async function decorateHeaders() {
const pullRequest = pullRequestLocation();
if (!pullRequest) return;
const headers = [...document.querySelectorAll('[data-diff-header-wrapper]')].filter((header) => !header.dataset.ghmdp);
if (!headers.length) return;
const index = await diffIndex(pullRequest).catch(() => null);
if (!index) return;
for (const header of headers) {
header.dataset.ghmdp = '1';
const summary = summaryFor(header, index);
if (!summary || !MARKDOWN_FILE.test(summary.path) || summary.changeType === 'DELETED') continue;
addPreviewButton(header, summary.path, index.headSha);
}
}
function closePreview() {
const overlay = document.getElementById(OVERLAY_ID);
if (!overlay) return;
overlay.remove();
document.documentElement.style.overflow = '';
}
function buildOverlay(path) {
closePreview();
const overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
overlay.innerHTML = `
<div class="ghmdp-bar">
<span class="ghmdp-path"></span>
<span class="ghmdp-count" hidden></span>
<button type="button" class="ghmdp-button ghmdp-previous" aria-label="Previous change" hidden>&#8593;</button>
<button type="button" class="ghmdp-button ghmdp-next" aria-label="Next change" hidden>&#8595;</button>
<span class="ghmdp-hint">esc</span>
<button type="button" class="ghmdp-button ghmdp-close" aria-label="Close preview">${icon(CLOSE_ICON, 16)}</button>
</div>
<div class="ghmdp-scroll"><article class="markdown-body"></article></div>
`;
overlay.querySelector('.ghmdp-path').textContent = path;
overlay.querySelector('.ghmdp-close').addEventListener('click', closePreview);
overlay.querySelector('.ghmdp-scroll').addEventListener('click', (event) => {
if (event.target === event.currentTarget) closePreview();
});
document.body.append(overlay);
document.documentElement.style.overflow = 'hidden';
return overlay;
}
function withoutFrontMatter(source) {
const frontMatter = source.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
if (!frontMatter) return { source, firstLine: 1 };
return {
source: source.slice(frontMatter[0].length),
firstLine: frontMatter[0].split('\n').length
};
}
function absolutize(element, attribute, base) {
const value = element.getAttribute(attribute);
if (!value || value.startsWith('#') || value.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(value)) return;
element.setAttribute(attribute, new URL(value, base).href);
}
function resolveRelativeReferences(root, pullRequest, headSha, path) {
const directory = path.replace(/[^/]*$/, '');
const repository = `https://github.com/${pullRequest.owner}/${pullRequest.repo}`;
root.querySelectorAll('img[src]').forEach((image) => absolutize(image, 'src', `${repository}/raw/${headSha}/${directory}`));
root.querySelectorAll('a[href]').forEach((link) => {
absolutize(link, 'href', `${repository}/blob/${headSha}/${directory}`);
link.target = '_blank';
link.rel = 'noopener noreferrer';
});
}
async function openPreview(path, headSha) {
const pullRequest = pullRequestLocation();
const overlay = buildOverlay(path);
const article = overlay.querySelector('.markdown-body');
article.textContent = 'Loading…';
try {
const encodedPath = path.split('/').map(encodeURIComponent).join('/');
const [response, diff] = await Promise.all([
fetch(`/${pullRequest.owner}/${pullRequest.repo}/raw/${headSha}/${encodedPath}`),
changedLinesByPath(pullRequest)
]);
if (!response.ok) throw new Error(`GitHub returned ${response.status}`);
const { source, firstLine } = withoutFrontMatter(await response.text());
const rendered = renderBlocks(source, diff.get(path), firstLine);
article.innerHTML = DOMPurify.sanitize(rendered.html, { USE_PROFILES: { html: true } });
resolveRelativeReferences(article, pullRequest, headSha, path);
enableChangeNavigation(overlay, rendered.changedBlocks);
article.parentElement.scrollTop = 0;
} catch (error) {
article.textContent = `Could not load ${path}: ${error.message}`;
}
}
function enableChangeNavigation(overlay, changedBlocks) {
const count = overlay.querySelector('.ghmdp-count');
const previous = overlay.querySelector('.ghmdp-previous');
const next = overlay.querySelector('.ghmdp-next');
if (!changedBlocks) return;
count.textContent = `${changedBlocks} changed`;
for (const element of [count, previous, next]) element.hidden = false;
const blocks = [...overlay.querySelectorAll('.ghmdp-changed')];
let cursor = -1;
const jump = (step) => {
cursor = (cursor + step + blocks.length) % blocks.length;
blocks[cursor].scrollIntoView({ block: 'center', behavior: 'smooth' });
};
next.addEventListener('click', () => jump(1));
previous.addEventListener('click', () => jump(-1));
}
let scheduled = 0;
function scheduleDecoration() {
clearTimeout(scheduled);
scheduled = setTimeout(decorateHeaders, 50);
}
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !document.getElementById(OVERLAY_ID)) return;
event.preventDefault();
event.stopPropagation();
closePreview();
}, true);
document.addEventListener('turbo:load', scheduleDecoration);
document.addEventListener('pjax:end', scheduleDecoration);
new MutationObserver(scheduleDecoration).observe(document.body, { childList: true, subtree: true });
scheduleDecoration();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment