Azure DevOps PR Summary Syntax Highlight v1.0.0
← Back to User Scripts
Script Content
// ==UserScript==
// @name Azure DevOps: PR Summary Syntax Highlight
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.0
// @description Adds real syntax colouring to the plain-text multi-file pull request diff summary view
// @author Tim Hilton using Claude
// @match https://dev.azure.com/*/_git/*/pullrequest/*
// @grant none
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-core.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-clike.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-csharp.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-javascript.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-typescript.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-markup.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-css.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-scss.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-json.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-sql.min.js
// @require https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/prism-yaml.min.js
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Azure DevOps: PR Summary Syntax Highlight]';
const LANGUAGE_BY_EXTENSION = {
cs: 'csharp',
ts: 'typescript',
tsx: 'typescript',
js: 'javascript',
jsx: 'javascript',
html: 'markup',
cshtml: 'markup',
razor: 'markup',
xml: 'markup',
csproj: 'markup',
css: 'css',
scss: 'scss',
json: 'json',
sql: 'sql',
yml: 'yaml',
yaml: 'yaml',
};
// Written by hand rather than @require-ing a theme stylesheet, since @require only fetches
// JS, not CSS. Every rule here sets color/font-weight/font-style ONLY, deliberately never
// background — that's the whole point of this script (see verifyThemeHasNoTokenBackground
// below, which checks this assumption stays true at runtime).
const THEME_CSS = `
.token.comment, .token.prolog, .token.doctype, .token.cdata { color: #9e9e9e; }
.token.punctuation { color: #d4d4d4; }
.token.tag, .token.attr-name, .token.namespace, .token.deleted { color: #e2777a; }
.token.function-name, .token.function { color: #6196cc; }
.token.boolean, .token.number { color: #f08d49; }
.token.property, .token.class-name, .token.constant, .token.symbol { color: #f8c555; }
.token.selector, .token.important, .token.atrule, .token.keyword, .token.builtin { color: #cc99cd; }
.token.string, .token.char, .token.attr-value, .token.regex, .token.variable { color: #7ec699; }
.token.operator, .token.entity, .token.url { color: #67cdcc; }
.token.important, .token.bold { font-weight: bold; }
.token.italic { font-style: italic; }
`;
let trustedTypesPolicy = null;
function getTrustedTypesPolicy() {
if (trustedTypesPolicy) {
return trustedTypesPolicy;
}
if (!window.trustedTypes || !window.trustedTypes.createPolicy) {
return null;
}
try {
trustedTypesPolicy = window.trustedTypes.createPolicy('ado-syntax-highlight', {
createHTML: (s) => s,
});
} catch (error) {
console.log(`${LOG_PREFIX} ❌ Failed to create Trusted Types policy: ${error.message}`);
}
return trustedTypesPolicy;
}
function setInnerHtml(el, html) {
try {
el.innerHTML = html;
} catch (error) {
const policy = getTrustedTypesPolicy();
if (!policy) {
console.log(`${LOG_PREFIX} ❌ Could not set innerHTML: ${error.message}`);
return;
}
el.innerHTML = policy.createHTML(html);
}
}
function injectThemeStylesheet() {
const style = document.createElement('style');
style.textContent = THEME_CSS;
document.head.appendChild(style);
console.debug(`${LOG_PREFIX} Injected token colour stylesheet`);
}
// Confirms THEME_CSS (and Prism's own token classes, which never carry an ancestor
// pre[class*="language-"]/code[class*="language-"] in this DOM, so Prism's normal block-level
// theme rules can't apply here anyway) never renders a background on a `.token` element.
function verifyThemeHasNoTokenBackground() {
const probe = document.createElement('span');
probe.className = 'token keyword';
probe.style.position = 'absolute';
probe.style.visibility = 'hidden';
probe.textContent = 'x';
document.body.appendChild(probe);
const backgroundColor = getComputedStyle(probe).backgroundColor;
probe.remove();
const hasBackground = backgroundColor && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent';
if (hasBackground) {
console.log(
`${LOG_PREFIX} ❌ .token rules resolve to a background (${backgroundColor}) — this violates the text-colour-only requirement`,
);
}
return !hasBackground;
}
// The header row's textContent has no whitespace between the file path and the trailing
// "View" button label (e.g. ".../PaymentCalculator.csView"), so the extension must only
// match lowercase/digit characters — real extensions in this UI are always lowercase, while
// button labels are always title-case, so this stops the match before "View" starts.
function extractFilePath(headerText) {
const matches = headerText.match(/\/[^\s]+\.[a-z0-9]+/g);
if (!matches || matches.length === 0) {
return null;
}
return matches[matches.length - 1];
}
function getLanguageForPath(filePath) {
const extensionMatch = filePath.match(/\.([A-Za-z0-9]+)$/);
if (!extensionMatch) {
return null;
}
const language = LANGUAGE_BY_EXTENSION[extensionMatch[1].toLowerCase()];
if (!language) {
return null;
}
if (!window.Prism || !window.Prism.languages || !window.Prism.languages[language]) {
return null;
}
return language;
}
function escapeHtml(text) {
return text.replace(/&/g, '&').replace(//g, '>');
}
// A line's actual code text is not always wrapped in a : an ordinary, uncommented
// line renders it as a bare text node directly under .repos-line-content, sandwiched
// between the screen-reader-only marker and a zero-length icon span. Only lines with special
// handling (an in-line edit, or an attached comment thread — confirmed live via
// `.diff-comment`) wrap the text in a . Segments must therefore walk childNodes (which
// includes text nodes), not just children (elements only), or plain lines are silently
// skipped entirely.
function collectSegments(lineContentEl) {
const segments = [];
let offset = 0;
for (const node of Array.from(lineContentEl.childNodes)) {
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('screen-reader-only')) {
continue;
}
if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.TEXT_NODE) {
continue;
}
const text = node.textContent;
segments.push({ node, start: offset, end: offset + text.length });
offset += text.length;
}
return segments;
}
// Prism.tokenize() returns top-level strings and Prism.Token instances. A Token's `.length`
// is set from the original matched substring, so summing lengths in order reconstructs exact
// offsets into lineText without needing to walk nested sub-tokens.
function flattenTokenRanges(tokens) {
const ranges = [];
let offset = 0;
for (const token of tokens) {
const type = typeof token === 'string' ? null : token.type;
ranges.push({ start: offset, end: offset + token.length, type });
offset += token.length;
}
return ranges;
}
function buildSegmentHtml(segmentText, segmentStart, segmentEnd, ranges) {
let html = '';
for (const range of ranges) {
const overlapStart = Math.max(range.start, segmentStart);
const overlapEnd = Math.min(range.end, segmentEnd);
if (overlapStart >= overlapEnd) {
continue;
}
const piece = escapeHtml(segmentText.slice(overlapStart - segmentStart, overlapEnd - segmentStart));
html += range.type ? `${piece}` : piece;
}
return html;
}
function processLineContent(lineContentEl, language) {
if (lineContentEl.dataset.adoSyntaxDone) {
return;
}
const segments = collectSegments(lineContentEl);
const lineText = segments.map((segment) => segment.node.textContent).join('');
if (lineText.trim().length === 0) {
lineContentEl.dataset.adoSyntaxDone = '1';
return;
}
let tokens;
try {
tokens = window.Prism.tokenize(lineText, window.Prism.languages[language]);
} catch (error) {
console.log(`${LOG_PREFIX} ❌ Failed to tokenize line: ${error.message}`);
lineContentEl.dataset.adoSyntaxDone = '1';
return;
}
const ranges = flattenTokenRanges(tokens);
for (const segment of segments) {
const text = segment.node.textContent;
if (text.length === 0) {
continue;
}
const html = buildSegmentHtml(text, segment.start, segment.end, ranges);
if (segment.node.nodeType === Node.TEXT_NODE) {
// A bare text node has no innerHTML to set — replace it with a plain
// carrying no class or style of its own, so the ancestor row's background
// still shows through unchanged (only the newly-added nested `.token` spans set
// a color/font-style).
const wrapper = document.createElement('span');
setInnerHtml(wrapper, html);
segment.node.replaceWith(wrapper);
} else {
setInnerHtml(segment.node, html);
}
}
lineContentEl.dataset.adoSyntaxDone = '1';
}
function getFilePathFromHeader(headerEl) {
const flexRow = headerEl.querySelector(':scope > .flex-row');
if (!flexRow) {
return null;
}
return extractFilePath(flexRow.textContent);
}
function processHeaderCard(headerEl) {
const filePath = getFilePathFromHeader(headerEl);
if (!filePath) {
return;
}
const language = getLanguageForPath(filePath);
if (!language) {
console.debug(`${LOG_PREFIX} ⏭️ Skipping ${filePath} (no supported Prism language)`);
return;
}
const diffBody = headerEl.querySelector('.repos-summary-code-diff');
if (!diffBody) {
return;
}
const lineContents = diffBody.querySelectorAll('.repos-line-content:not([data-ado-syntax-done])');
lineContents.forEach((lineContentEl) => processLineContent(lineContentEl, language));
}
function processAllVisibleCards() {
const headers = document.querySelectorAll('.repos-summary-header');
console.debug(`${LOG_PREFIX} Scanning ${headers.length} file card(s)`);
headers.forEach(processHeaderCard);
}
function findObserverRoot() {
return document.querySelector('.repos-changes-viewer') || document.querySelector('.repos-changes-explorer-splitter');
}
function startObserving(root) {
let debounceTimer = null;
let mutationCount = 0;
const observer = new MutationObserver(() => {
mutationCount++;
console.debug(`${LOG_PREFIX} MutationObserver fired (count: ${mutationCount})`);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(processAllVisibleCards, 75);
});
observer.observe(root, { childList: true, subtree: true });
console.log(`${LOG_PREFIX} ✅ Observer attached to diff viewer`);
}
function init() {
if (!window.Prism) {
console.log(`${LOG_PREFIX} ❌ Prism.js did not load, skipping syntax highlighting`);
return;
}
injectThemeStylesheet();
verifyThemeHasNoTokenBackground();
processAllVisibleCards();
const root = findObserverRoot();
if (root) {
startObserving(root);
console.log(`${LOG_PREFIX} 🎉 Script initialized successfully`);
return;
}
console.debug(`${LOG_PREFIX} Diff viewer not found yet, waiting for it to appear`);
const waitObserver = new MutationObserver(() => {
const foundRoot = findObserverRoot();
if (!foundRoot) {
return;
}
waitObserver.disconnect();
processAllVisibleCards();
startObserving(foundRoot);
console.log(`${LOG_PREFIX} 🎉 Script initialized successfully (delayed)`);
});
waitObserver.observe(document.body, { childList: true, subtree: true });
}
console.debug(`${LOG_PREFIX} Initializing script`);
init();
})();