Last active
September 14, 2026 09:08
-
-
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
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 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>↑</button> | |
| <button type="button" class="ghmdp-button ghmdp-next" aria-label="Next change" hidden>↓</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