Skip to content

Instantly share code, notes, and snippets.

@doubleedesign
Created November 17, 2024 02:47
Show Gist options
  • Save doubleedesign/e6a22ff3252cbb3feb21a04ad2a37670 to your computer and use it in GitHub Desktop.
Save doubleedesign/e6a22ff3252cbb3feb21a04ad2a37670 to your computer and use it in GitHub Desktop.
Customised Xdebug output styling
/**
* Customise some of the HTML output of xdebug + add highlightjs + hackily customise that
* Requires:
* - HighlightJS: https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js
* - HighlightJS PHP: https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/php.min.js
* - Optionally, a base theme, e.g., https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github.min.css
**/
document.addEventListener('DOMContentLoaded', function () {
const codeBlocks = document.querySelectorAll('.xdebug-error td:nth-of-type(4)');
if(codeBlocks.length) {
codeBlocks.forEach((codeBlock) => {
codeBlock.classList.add('php');
// Add highlighting
// Note: This needs to happen before manipulating the content below, or else things break
hljs.highlightElement(codeBlock);
// Wrap truncated arrays and objects in a span
wrapTruncatedObjects(codeBlock);
// Add an extra class to string elements that are WordPress block output strings
wrapWpBlockHtml(codeBlock);
// Use a regular expression to match the word "class" followed by a class name
// and wrap the class name in a span with a class that matches what highlight.js uses for others
wrapKeywords(codeBlock);
// Detect and wrap arrays
if(codeBlock.innerText.includes('[') && codeBlock.innerText.includes(']')) {
wrapArrayContents(codeBlock);
}
// Detect classes and add a wrapping div to their inner contents
if(codeBlock.innerText.includes('{') && codeBlock.innerText.includes('}')) {
replaceFirstAndLastBraces(codeBlock);
}
// Detect function arguments using () and add a wrapping span to their inner contents
if(codeBlock.innerText.includes('(') && codeBlock.innerText.includes(')')) {
wrapFunctionArgs(codeBlock);
}
// Detect and wrap namespaces and file paths (done together because they both use backslashes)
if(codeBlock.innerText.includes('\\')) {
wrapNamespacesAndFilePaths(codeBlock);
}
});
}
});
function wrapTruncatedObjects(node) {
node.innerHTML = node.innerHTML
.replaceAll('= [...];', '<span class="hljs-array-truncated">= [...];</span>')
.replaceAll('= [...]', '<span class="hljs-array-truncated">= [...]</span>')
.replaceAll('=> { ... };', '<span class="hljs-class-truncated">=> { ... };</span>')
.replaceAll('{ ... };', '<span class="hljs-class-truncated">{ ... };</span>')
}
function wrapWpBlockHtml(node) {
const stringElements = node.querySelectorAll('.hljs-string');
const blockElements = Array.from(stringElements).filter((el) => {
const content = el.textContent.trim();
return content.startsWith("'<!-- wp:") && content.endsWith("-->'");
});
blockElements.forEach((el) => {
el.classList.add('hljs-wp-block');
// Split the content into parts at \n and wrap each newline in a span
const parts = el.innerHTML.split(/\\n/);
const wrappedContent = parts.map(part => {
return `<span class="hljs-wp-block-line">${part}</span>`;
}).join('');
el.innerHTML = wrappedContent;
});
}
function wrapKeywords(node) {
const keywordSpans = node.querySelectorAll('span.hljs-keyword');
keywordSpans.forEach((span) => {
if (span.textContent.trim() === 'class') {
// Update the class name of this span to identify it as a class keyword
span.classList.add('class_');
// Traverse siblings until we find the class name as plain text
let sibling = span.nextSibling;
while (sibling && (sibling.nodeType !== node.TEXT_NODE || sibling.textContent.trim() === '')) {
sibling = sibling.nextSibling;
}
if (sibling && sibling.nodeType === node.TEXT_NODE) {
// Extract the class name using regex
const match = sibling.textContent.trim().match(/^([a-zA-Z0-9_]+)/);
if (match) {
const className = match[1];
const wrappedClassName = `<span class="hljs-title class_">${className}</span>`;
const newHTML = sibling.textContent.replace(className, wrappedClassName);
// Create a temporary container to parse the HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newHTML;
// Insert the parsed HTML back into the DOM and remove the original node
while (tempDiv.firstChild) {
sibling.parentNode.insertBefore(tempDiv.firstChild, sibling);
}
sibling.parentNode.removeChild(sibling);
}
}
}
});
}
function wrapFunctionArgs(node) {
// Add an opening div after the opening (
node.innerHTML = node.innerHTML.replace('(', '(<span class="hljs-function-args">');
// Add a closing div before the closing )
node.innerHTML = node.innerHTML.replace(')', '</span>)');
}
function replaceFirstAndLastBraces(node) {
const htmlContent = node.innerHTML;
// Find the position of the first `{` and the last `}`
const firstBraceIndex = htmlContent.indexOf('{');
const lastBraceIndex = htmlContent.lastIndexOf('}');
if (firstBraceIndex !== -1 && lastBraceIndex !== -1) {
// Split the HTML content into three parts
const beforeFirstBrace = htmlContent.substring(0, firstBraceIndex);
const firstBrace = '{<div class="hljs-class-contents">';
const betweenBraces = htmlContent.substring(firstBraceIndex + 1, lastBraceIndex);
const lastBrace = '</div>}';
const afterLastBrace = htmlContent.substring(lastBraceIndex + 1);
// Reconstruct the HTML with the wrapping div
node.innerHTML = beforeFirstBrace + firstBrace + betweenBraces + lastBrace + afterLastBrace;
}
}
function wrapArrayContents(node) {
// Wrap [ ... ] and their inner contents, excluding [ ... ]
node.innerHTML = node.innerHTML.replace(/\[(?!\s*\.\.\.\s*\])([^\[\]]*?)\]/g, (match, innerContent) => {
// Split the inner content on commas
const parts = innerContent.split(',');
// Wrap each part in its own span
const wrappedParts = parts.map(part => `<span class="hljs-array-item">${part},</span>`);
// Rejoin the parts
const wrappedContentItems = wrappedParts.join('');
return `<span class="hljs-array">[<div class="hljs-array-contents">${wrappedContentItems}</div>]</span>`;
});
}
function wrapNamespacesAndFilePaths(node) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null);
let currentNode;
while ((currentNode = walker.nextNode())) {
// Skip text nodes that are children of .hljs-string spans
if (currentNode.parentNode.classList && currentNode.parentNode.classList.contains('hljs-string')) {
continue;
}
// Check if the text node contains a backslash
if (currentNode.nodeValue.includes('\\')) {
// Split the text content by `->` to separate parts
const parts = currentNode.nodeValue.split('->');
// Create a document fragment to rebuild the node
const fragment = document.createDocumentFragment();
parts.forEach((part, index) => {
if (part.includes('\\')) {
// Create a span for parts with backslashes
const span = document.createElement('span');
if(currentNode.nodeValue.includes('C:\\')) {
span.className = 'hljs-filepath';
} else {
span.className = 'hljs-namespace';
}
span.textContent = part;
fragment.appendChild(span);
}
else if (part) {
// Add plain text for parts without backslashes
fragment.appendChild(document.createTextNode(part));
}
// Add `->` back between parts, except after the last one
if (index < parts.length - 1) {
fragment.appendChild(document.createTextNode('->'));
}
});
// Replace the original text node with the fragment
currentNode.parentNode.replaceChild(fragment, currentNode);
}
}
}
/**
* Note: Some of these styles rely on additional classes and wrapping elements that are added using JS
* (both using highlight.js and some custom code).
* Also, CSS ordering has been implemented to put the most useful stack trace messages at the top,
* but note that this makes keyboard navigation go backwards if you have links enabled.
**/
:root {
--xdebug-link-color: #a275ad;
--xdebug-link-hover-color: #9bdeac;
--xdebug-warning-color: #ffb57a;
--xdebug-code-variable-color: #9b48de;
--xdebug-code-string-color: #5b5b5b;
--xdebug-code-keyword-color: #472bc4;
--xdebug-code-class-color: #0a9bbb;
--xdebug-code-function-color: #28b06b;
}
.xdebug-error {
font-family: Arial, Helvetica, sans-serif;
background: white;
max-width: 900px;
margin: 1rem auto;
border: 0;
font-size: 0.9rem;
a {
color: var(--xdebug-link-color) !important;
&:hover, &:focus, &:active {
color: var(--xdebug-link-hover-color) !important;
}
}
/* Reverse the order of the stack trace because it's more useful that way */
/* But note this does make tab order the wrong way around if navigating via keyboard */
tbody {
display: flex;
flex-direction: column-reverse;
/* Put the main error messages back at the top */
tr:has(th[colspan="5"]) {
order: 10;
}
}
/* Error message block */
th[colspan="5"] {
padding: 0.5rem;
display: block;
margin-block-end: 1rem;
background: white;
border: 1px solid var(--xdebug-warning-color);
border-left: 2rem solid var(--xdebug-warning-color);
font-weight: normal;
line-height: 1.4;
;
> span:first-child {
display: none;
}
a {
display: block;
}
}
/* Row that says "Call stack" */
tr:has(th[bgcolor="#e9b96e"]) {
display: none;
}
/* Call stack column headers */
tr:has(th:nth-child(5)) {
display: none;
}
/* Call stack rows */
tr:has(td:nth-child(5)) {
display: grid;
grid-template-columns: 2rem repeat(5, 1fr);
grid-template-rows: repeat(3, auto);
grid-gap: 0;
margin-block-end: 1rem;
border: 1px solid #ccc;
td {
border: 0;
text-align: left;
background: transparent;
align-items: center;
padding: 0.5rem;
&:before {
font-family: Arial, Helvetica, sans-serif;
font-weight: bold;
display: inline-block;
}
&:nth-child(1) {
grid-area: 1 / 1 / 4 / 2;
background: #EDEDED;
}
&:nth-child(2),
&:nth-child(3) {
text-align: right;
font-size: 0.7rem;
}
&:nth-child(2) {
grid-area: 1 / 5;
&:before {
content: 'Time: ';
}
}
&:nth-child(3) {
grid-area: 1 / 6;
&:before {
content: 'Memory: ';
}
}
&:nth-child(4) {
grid-area: 2 / 2 / 3 / 7;
font-family: 'Fira Code', monospace;
line-height: 1.4;
border-bottom: 1px solid #DDD;
border-top: 1px solid #DDD;
margin-inline: 0.25rem;
&:before {
content: 'Function: ';
display: block;
margin-block-end: 0.25rem;
}
font {
display: inline-block;
max-width: 750px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* Reverse the overflow direction */
vertical-align: middle;
margin-inline-start: -0.75rem;
margin-inline-end: -0.75rem;
}
}
&:nth-child(5) {
grid-area: 3 / 2 / 4 / 7;
display: flex;
align-items: center;
&:before {
content: 'Location: ';
display: block;
}
&:after {
content: attr(title);
margin-left: auto;
max-width: 600px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl; /* Reverse the overflow direction */
}
}
}
}
/* highlightjs syntax highlighting overrides */
.hljs.language-php {
}
/** Colours **/
.hljs-keyword {
color: var(--xdebug-code-keyword-color);
}
.hljs-title.class_,
.hljs-class .hljs-title,
.hljs-namespace {
color: var(--xdebug-code-class-color);
}
.hljs-variable {
color: var(--xdebug-code-variable-color);
}
.hljs-title.function_ {
color: var(--xdebug-code-function-color);
}
.hljs-string,
.hljs-array-truncated,
.hljs-class-truncated {
color: var(--xdebug-code-string-color);
}
/** Spacing and hacky line breaks **/
.hljs-array {
}
.hljs-array-contents,
.hljs-class-contents {
margin-left: 2rem;
}
.hljs-array-item {
display: block;
}
/* Put args on the next line and indent them if there's 3 or more */
.hljs-function-args {
&:not(:has(:first-child)) { /* :empty doesn't work because there's whitespace */
display: none;
}
&:has(:nth-child(3)) {
display: block;
margin-left: 2rem;
> .hljs-variable {
&:before {
display: block;
margin-left: 1em;
height: 0;
width: 100%;
content: '\A'; /* Inserts a line break */
white-space: pre; /* Ensures the line break is rendered */
}
}
}
}
.hljs-class-contents > .hljs-keyword:not(.class_),
.hljs-array .hljs-variable {
&:before {
display: block;
height: 0;
width: 100%;
content: '\A'; /* Inserts a line break */
white-space: pre; /* Ensures the line break is rendered */
}
}
.hljs-title.function_ {
}
.hljs-wp-block {
display: block;
margin-left: 2rem;
.hljs-wp-block-line {
display: block;
}
.hljs-class-contents {
margin: 0;
display: contents;
}
}
/** Truncate long strings **/
.hljs-string:not(.hljs-wp-block) {
display: inline-block;
max-width: 600px;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
<?php
/**
* Enqueue styles and scripts to make xdebug output more readable for admins in local environments
* Note: WP_ENVIRONMENT_TYPE is a constant defined in wp-config.php
*
* @return void
*/
function doublee_make_xdebug_pretty(): void {
if (defined('WP_ENVIRONMENT_TYPE') && WP_ENVIRONMENT_TYPE === 'local' && current_user_can('administrator')) {
wp_enqueue_style(
'xdebug-styles',
'/wp-content/plugins/doublee-base-plugin/assets/xdebug-styles.css',
[],
'1.0.0'
);
wp_enqueue_style('highlight-code', 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github.min.css', [], '11.8.0'); // base theme
wp_enqueue_script('highlight-js', 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js', [], '11.8.0');
wp_enqueue_script('highlight-php', 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/php.min.js', [], '11.8.0');
wp_enqueue_script('xdebug-markup', '/wp-content/plugins/doublee-base-plugin/assets/xdebug-markup.js', [], '1.0.0');
}
}
add_action('wp_enqueue_scripts', 'doublee_make_xdebug_pretty');
add_action('admin_enqueue_scripts', 'doublee_make_xdebug_pretty');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment