0.2.0 - Mid migration

This commit is contained in:
Daniel Mason 2022-04-25 14:47:15 +12:00
parent 139e6a915e
commit 7e38fdbd7d
42393 changed files with 5358157 additions and 62 deletions

View file

@ -0,0 +1,52 @@
const ansiHTML = require('ansi-html');
const HtmlEntities = require('html-entities');
const theme = require('../theme');
const formatFilename = require('../utils/formatFilename');
ansiHTML.setColors(theme);
const entities = new HtmlEntities.Html5Entities();
/**
* @typedef {Object} CompileErrorTraceProps
* @property {string} errorMessage
*/
/**
* A formatter that turns Webpack compile error messages into highlighted HTML source traces.
* @param {Document} document
* @param {HTMLElement} root
* @param {CompileErrorTraceProps} props
* @returns {void}
*/
function CompileErrorTrace(document, root, props) {
const errorParts = props.errorMessage.split('\n');
const errorMessage = errorParts
.splice(1, 1)[0]
// Strip filename from the error message
.replace(/^(.*:)\s.*:(\s.*)$/, '$1$2');
errorParts[0] = formatFilename(errorParts[0]);
errorParts.unshift(errorMessage);
const stackContainer = document.createElement('pre');
stackContainer.innerHTML = entities.decode(ansiHTML(entities.encode(errorParts.join('\n'))));
stackContainer.style.fontFamily = [
'"Operator Mono SSm"',
'"Operator Mono"',
'"Fira Code Retina"',
'"Fira Code"',
'"FiraCode-Retina"',
'"Andale Mono"',
'"Lucida Console"',
'Menlo',
'Consolas',
'Monaco',
'monospace',
].join(', ');
stackContainer.style.margin = '0';
stackContainer.style.whiteSpace = 'pre-wrap';
root.appendChild(stackContainer);
}
module.exports = CompileErrorTrace;

View file

@ -0,0 +1,56 @@
const theme = require('../theme');
const Spacer = require('./Spacer');
/**
* @typedef {Object} PageHeaderProps
* @property {string} [message]
* @property {string} title
* @property {string} [topOffset]
*/
/**
* The header of the overlay.
* @param {Document} document
* @param {HTMLElement} root
* @param {PageHeaderProps} props
* @returns {void}
*/
function PageHeader(document, root, props) {
const pageHeaderContainer = document.createElement('div');
pageHeaderContainer.style.background = '#' + theme.dimgrey;
pageHeaderContainer.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.3)';
pageHeaderContainer.style.color = '#' + theme.white;
pageHeaderContainer.style.left = '0';
pageHeaderContainer.style.padding = '1rem 1.5rem';
pageHeaderContainer.style.position = 'fixed';
pageHeaderContainer.style.top = props.topOffset || '0';
pageHeaderContainer.style.width = 'calc(100vw - 3rem)';
const title = document.createElement('h3');
title.innerText = props.title;
title.style.color = '#' + theme.red;
title.style.fontSize = '1.125rem';
title.style.lineHeight = '1.3';
title.style.margin = '0';
pageHeaderContainer.appendChild(title);
if (props.message) {
title.style.margin = '0 0 0.5rem';
const message = document.createElement('span');
message.innerText = props.message;
message.style.color = '#' + theme.white;
message.style.wordBreak = 'break-word';
pageHeaderContainer.appendChild(message);
}
root.appendChild(pageHeaderContainer);
// This has to run after appending elements to root
// because we need to actual mounted height.
Spacer(document, root, {
space: pageHeaderContainer.offsetHeight.toString(10),
});
}
module.exports = PageHeader;

View file

@ -0,0 +1,91 @@
const theme = require('../theme');
const Spacer = require('./Spacer');
/**
* @typedef {Object} RuntimeErrorFooterProps
* @property {string} [initialFocus]
* @property {boolean} multiple
* @property {function(MouseEvent): void} onClickCloseButton
* @property {function(MouseEvent): void} onClickNextButton
* @property {function(MouseEvent): void} onClickPrevButton
*/
/**
* A fixed footer that handles pagination of runtime errors.
* @param {Document} document
* @param {HTMLElement} root
* @param {RuntimeErrorFooterProps} props
* @returns {void}
*/
function RuntimeErrorFooter(document, root, props) {
const footer = document.createElement('div');
footer.style.backgroundColor = '#' + theme.dimgrey;
footer.style.bottom = '0';
footer.style.boxShadow = '0 -1px 4px rgba(0, 0, 0, 0.3)';
footer.style.height = '2.5rem';
footer.style.left = '0';
footer.style.lineHeight = '2.5rem';
footer.style.position = 'fixed';
footer.style.textAlign = 'center';
footer.style.width = '100vw';
footer.style.zIndex = '2';
const BUTTON_CONFIGS = {
prev: {
id: 'prev',
label: '◀ Prev',
onClick: props.onClickPrevButton,
},
close: {
id: 'close',
label: '× Close',
onClick: props.onClickCloseButton,
},
next: {
id: 'next',
label: 'Next ▶',
onClick: props.onClickNextButton,
},
};
let buttons = [BUTTON_CONFIGS.close];
if (props.multiple) {
buttons = [BUTTON_CONFIGS.prev, BUTTON_CONFIGS.close, BUTTON_CONFIGS.next];
}
/** @type {HTMLButtonElement | undefined} */
let initialFocusButton;
for (let i = 0; i < buttons.length; i += 1) {
const buttonConfig = buttons[i];
const button = document.createElement('button');
button.id = buttonConfig.id;
button.innerHTML = buttonConfig.label;
button.tabIndex = 1;
button.style.backgroundColor = '#' + theme.dimgrey;
button.style.border = 'none';
button.style.color = '#' + theme.white;
button.style.cursor = 'pointer';
button.style.fontSize = 'inherit';
button.style.height = '100%';
button.style.padding = '0.5rem 0.75rem';
button.style.width = (100 / buttons.length).toString(10) + '%';
button.addEventListener('click', buttonConfig.onClick);
if (buttonConfig.id === props.initialFocus) {
initialFocusButton = button;
}
footer.appendChild(button);
}
root.appendChild(footer);
Spacer(document, root, { space: '2.5rem' });
if (initialFocusButton) {
initialFocusButton.focus();
}
}
module.exports = RuntimeErrorFooter;

View file

@ -0,0 +1,37 @@
const theme = require('../theme');
const Spacer = require('./Spacer');
/**
* @typedef {Object} RuntimeErrorHeaderProps
* @property {number} currentErrorIndex
* @property {number} totalErrors
*/
/**
* A fixed header that shows the total runtime error count.
* @param {Document} document
* @param {HTMLElement} root
* @param {RuntimeErrorHeaderProps} props
* @returns {void}
*/
function RuntimeErrorHeader(document, root, props) {
const header = document.createElement('div');
header.innerText = 'Error ' + (props.currentErrorIndex + 1) + ' of ' + props.totalErrors;
header.style.backgroundColor = '#' + theme.red;
header.style.color = '#' + theme.white;
header.style.fontWeight = '500';
header.style.height = '2.5rem';
header.style.left = '0';
header.style.lineHeight = '2.5rem';
header.style.position = 'fixed';
header.style.textAlign = 'center';
header.style.top = '0';
header.style.width = '100vw';
header.style.zIndex = '2';
root.appendChild(header);
Spacer(document, root, { space: '2.5rem' });
}
module.exports = RuntimeErrorHeader;

View file

@ -0,0 +1,79 @@
const ErrorStackParser = require('error-stack-parser');
const theme = require('../theme');
const formatFilename = require('../utils/formatFilename');
/**
* @typedef {Object} RuntimeErrorStackProps
* @property {Error} error
*/
/**
* A formatter that turns runtime error stacks into highlighted HTML stacks.
* @param {Document} document
* @param {HTMLElement} root
* @param {RuntimeErrorStackProps} props
* @returns {void}
*/
function RuntimeErrorStack(document, root, props) {
const stackTitle = document.createElement('h4');
stackTitle.innerText = 'Call Stack';
stackTitle.style.color = '#' + theme.white;
stackTitle.style.fontSize = '1.0625rem';
stackTitle.style.fontWeight = '500';
stackTitle.style.lineHeight = '1.3';
stackTitle.style.margin = '0 0 0.5rem';
const stackContainer = document.createElement('div');
stackContainer.style.fontSize = '0.8125rem';
stackContainer.style.lineHeight = '1.3';
stackContainer.style.whiteSpace = 'pre-wrap';
let errorStacks;
try {
errorStacks = ErrorStackParser.parse(props.error);
} catch (e) {
errorStacks = [];
stackContainer.innerHTML = 'No stack trace is available for this error!';
}
for (let i = 0; i < Math.min(errorStacks.length, 10); i += 1) {
const currentStack = errorStacks[i];
const functionName = document.createElement('code');
functionName.innerHTML = '&emsp;' + currentStack.functionName || '(anonymous function)';
functionName.style.color = '#' + theme.yellow;
functionName.style.fontFamily = [
'"Operator Mono SSm"',
'"Operator Mono"',
'"Fira Code Retina"',
'"Fira Code"',
'"FiraCode-Retina"',
'"Andale Mono"',
'"Lucida Console"',
'Menlo',
'Consolas',
'Monaco',
'monospace',
].join(', ');
const fileName = document.createElement('div');
fileName.innerHTML =
'&emsp;&emsp;' +
formatFilename(currentStack.fileName) +
':' +
currentStack.lineNumber +
':' +
currentStack.columnNumber;
fileName.style.color = '#' + theme.white;
fileName.style.fontSize = '0.6875rem';
fileName.style.marginBottom = '0.25rem';
stackContainer.appendChild(functionName);
stackContainer.appendChild(fileName);
}
root.appendChild(stackTitle);
root.appendChild(stackContainer);
}
module.exports = RuntimeErrorStack;

View file

@ -0,0 +1,19 @@
/**
* @typedef {Object} SpacerProps
* @property {string} space
*/
/**
* An empty element to add spacing manually.
* @param {Document} document
* @param {HTMLElement} root
* @param {SpacerProps} props
* @returns {void}
*/
function Spacer(document, root, props) {
const spacer = document.createElement('div');
spacer.style.paddingBottom = props.space;
root.appendChild(spacer);
}
module.exports = Spacer;

View file

@ -0,0 +1,25 @@
const CompileErrorTrace = require('../components/CompileErrorTrace');
const PageHeader = require('../components/PageHeader');
const Spacer = require('../components/Spacer');
/**
* @typedef {Object} CompileErrorContainerProps
* @property {string} errorMessage
*/
/**
* A container to render Webpack compilation error messages with source trace.
* @param {Document} document
* @param {HTMLElement} root
* @param {CompileErrorContainerProps} props
* @returns {void}
*/
function CompileErrorContainer(document, root, props) {
PageHeader(document, root, {
title: 'Failed to compile.',
});
CompileErrorTrace(document, root, { errorMessage: props.errorMessage });
Spacer(document, root, { space: '1rem' });
}
module.exports = CompileErrorContainer;

View file

@ -0,0 +1,29 @@
const PageHeader = require('../components/PageHeader');
const RuntimeErrorStack = require('../components/RuntimeErrorStack');
const Spacer = require('../components/Spacer');
/**
* @typedef {Object} RuntimeErrorContainerProps
* @property {Error} currentError
*/
/**
* A container to render runtime error messages with stack trace.
* @param {Document} document
* @param {HTMLElement} root
* @param {RuntimeErrorContainerProps} props
* @returns {void}
*/
function RuntimeErrorContainer(document, root, props) {
PageHeader(document, root, {
message: props.currentError.message,
title: props.currentError.name,
topOffset: '2.5rem',
});
RuntimeErrorStack(document, root, {
error: props.currentError,
});
Spacer(document, root, { space: '1rem' });
}
module.exports = RuntimeErrorContainer;

View file

@ -0,0 +1,334 @@
const RuntimeErrorFooter = require('./components/RuntimeErrorFooter');
const RuntimeErrorHeader = require('./components/RuntimeErrorHeader');
const CompileErrorContainer = require('./containers/CompileErrorContainer');
const RuntimeErrorContainer = require('./containers/RuntimeErrorContainer');
const theme = require('./theme');
const debounce = require('./utils/debounce');
const removeAllChildren = require('./utils/removeAllChildren');
/**
* @callback RenderFn
* @returns {void}
*/
/* ===== Cached elements for DOM manipulations ===== */
/**
* The iframe that contains the overlay.
* @type {HTMLIFrameElement}
*/
let iframeRoot = null;
/**
* The document object from the iframe root, used to create and render elements.
* @type {Document}
*/
let rootDocument = null;
/**
* The root div elements will attach to.
* @type {HTMLDivElement}
*/
let root = null;
/**
* A Cached function to allow deferred render.
* @type {RenderFn | null}
*/
let scheduledRenderFn = null;
/* ===== Overlay State ===== */
/**
* The latest error message from Webpack compilation.
* @type {string}
*/
let currentCompileErrorMessage = '';
/**
* Index of the error currently shown by the overlay.
* @type {number}
*/
let currentRuntimeErrorIndex = 0;
/**
* The latest runtime error objects.
* @type {Error[]}
*/
let currentRuntimeErrors = [];
/**
* The render mode the overlay is currently in.
* @type {'compileError' | 'runtimeError' | null}
*/
let currentMode = null;
/**
* @typedef {Object} IframeProps
* @property {function(): void} onIframeLoad
*/
/**
* Creates the main `iframe` the overlay will attach to.
* Accepts a callback to be ran after iframe is initialized.
* @param {Document} document
* @param {HTMLElement} root
* @param {IframeProps} props
* @returns {HTMLIFrameElement}
*/
function IframeRoot(document, root, props) {
const iframe = document.createElement('iframe');
iframe.id = 'react-refresh-overlay';
iframe.src = 'about:blank';
iframe.style.border = 'none';
iframe.style.height = '100vh';
iframe.style.left = '0';
iframe.style.position = 'fixed';
iframe.style.top = '0';
iframe.style.width = '100vw';
iframe.style.zIndex = '2147483647';
iframe.addEventListener('load', function onLoad() {
// Reset margin of iframe body
iframe.contentDocument.body.style.margin = '0';
props.onIframeLoad();
});
// We skip mounting and returns as we need to ensure
// the load event is fired after we setup the global variable
return iframe;
}
/**
* Creates the main `div` element for the overlay to render.
* @param {Document} document
* @param {HTMLElement} root
* @returns {HTMLDivElement}
*/
function OverlayRoot(document, root) {
const div = document.createElement('div');
div.id = 'react-refresh-overlay-error';
// Style the contents container
div.style.backgroundColor = '#' + theme.grey;
div.style.boxSizing = 'border-box';
div.style.color = '#' + theme.white;
div.style.fontFamily = [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'"Helvetica Neue"',
'Helvetica',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'Segoe UI Symbol',
].join(', ');
div.style.fontSize = '0.875rem';
div.style.height = '100vh';
div.style.lineHeight = '1.3';
div.style.overflow = 'auto';
div.style.padding = '1rem 1.5rem 0';
div.style.width = '100vw';
root.appendChild(div);
return div;
}
/**
* Ensures the iframe root and the overlay root are both initialized before render.
* If check fails, render will be deferred until both roots are initialized.
* @param {RenderFn} renderFn A function that triggers a DOM render.
* @returns {void}
*/
function ensureRootExists(renderFn) {
if (root) {
// Overlay root is ready, we can render right away.
renderFn();
return;
}
// Creating an iframe may be asynchronous so we'll defer render.
// In case of multiple calls, function from the last call will be used.
scheduledRenderFn = renderFn;
if (iframeRoot) {
// Iframe is already ready, it will fire the load event.
return;
}
// Create the iframe root, and, the overlay root inside it when it is ready.
iframeRoot = IframeRoot(document, document.body, {
onIframeLoad: function onIframeLoad() {
rootDocument = iframeRoot.contentDocument;
root = OverlayRoot(rootDocument, rootDocument.body);
scheduledRenderFn();
},
});
// We have to mount here to ensure `iframeRoot` is set when `onIframeLoad` fires.
// This is because onIframeLoad() will be called synchronously
// or asynchronously depending on the browser.
document.body.appendChild(iframeRoot);
}
/**
* Creates the main `div` element for the overlay to render.
* @returns {void}
*/
function render() {
ensureRootExists(function () {
const currentFocus = rootDocument.activeElement;
let currentFocusId;
if (currentFocus.localName === 'button' && currentFocus.id) {
currentFocusId = currentFocus.id;
}
removeAllChildren(root);
if (currentCompileErrorMessage) {
currentMode = 'compileError';
CompileErrorContainer(rootDocument, root, {
errorMessage: currentCompileErrorMessage,
});
} else if (currentRuntimeErrors.length) {
currentMode = 'runtimeError';
RuntimeErrorHeader(rootDocument, root, {
currentErrorIndex: currentRuntimeErrorIndex,
totalErrors: currentRuntimeErrors.length,
});
RuntimeErrorContainer(rootDocument, root, {
currentError: currentRuntimeErrors[currentRuntimeErrorIndex],
});
RuntimeErrorFooter(rootDocument, root, {
initialFocus: currentFocusId,
multiple: currentRuntimeErrors.length > 1,
onClickCloseButton: function onClose() {
clearRuntimeErrors();
},
onClickNextButton: function onNext() {
if (currentRuntimeErrorIndex === currentRuntimeErrors.length - 1) {
return;
}
currentRuntimeErrorIndex += 1;
ensureRootExists(render);
},
onClickPrevButton: function onPrev() {
if (currentRuntimeErrorIndex === 0) {
return;
}
currentRuntimeErrorIndex -= 1;
ensureRootExists(render);
},
});
}
});
}
/**
* Destroys the state of the overlay.
* @returns {void}
*/
function cleanup() {
// Clean up and reset all internal state.
document.body.removeChild(iframeRoot);
scheduledRenderFn = null;
root = null;
iframeRoot = null;
}
/**
* Clears Webpack compilation errors and dismisses the compile error overlay.
* @returns {void}
*/
function clearCompileError() {
if (!root || currentMode !== 'compileError') {
return;
}
currentCompileErrorMessage = '';
currentMode = null;
cleanup();
}
/**
* Clears runtime error records and dismisses the runtime error overlay.
* @param {boolean} [dismissOverlay] Whether to dismiss the overlay or not.
* @returns {void}
*/
function clearRuntimeErrors(dismissOverlay) {
if (!root || currentMode !== 'runtimeError') {
return;
}
currentRuntimeErrorIndex = 0;
currentRuntimeErrors = [];
if (typeof dismissOverlay === 'undefined' || dismissOverlay) {
currentMode = null;
cleanup();
}
}
/**
* Shows the compile error overlay with the specific Webpack error message.
* @param {string} message
* @returns {void}
*/
function showCompileError(message) {
if (!message) {
return;
}
currentCompileErrorMessage = message;
render();
}
/**
* Shows the runtime error overlay with the specific error records.
* @param {Error[]} errors
* @returns {void}
*/
function showRuntimeErrors(errors) {
if (!errors || !errors.length) {
return;
}
currentRuntimeErrors = errors;
render();
}
/**
* The debounced version of `showRuntimeErrors` to prevent frequent renders
* due to rapid firing listeners.
* @param {Error[]} errors
* @returns {void}
*/
const debouncedShowRuntimeErrors = debounce(showRuntimeErrors, 30);
/**
* Detects if an error is a Webpack compilation error.
* @param {Error} error The error of interest.
* @returns {boolean} If the error is a Webpack compilation error.
*/
function isWebpackCompileError(error) {
return /Module [A-z ]+\(from/.test(error.message) || /Cannot find module/.test(error.message);
}
/**
* Handles runtime error contexts captured with EventListeners.
* Integrates with a runtime error overlay.
* @param {Error} error A valid error object.
* @returns {void}
*/
function handleRuntimeError(error) {
if (error && !isWebpackCompileError(error) && currentRuntimeErrors.indexOf(error) === -1) {
currentRuntimeErrors = currentRuntimeErrors.concat(error);
}
debouncedShowRuntimeErrors(currentRuntimeErrors);
}
module.exports = Object.freeze({
clearCompileError: clearCompileError,
clearRuntimeErrors: clearRuntimeErrors,
handleRuntimeError: handleRuntimeError,
showCompileError: showCompileError,
showRuntimeErrors: showRuntimeErrors,
});

View file

@ -0,0 +1,39 @@
/**
* @typedef {Object} Theme
* @property {string[]} reset
* @property {string} black
* @property {string} red
* @property {string} green
* @property {string} yellow
* @property {string} blue
* @property {string} magenta
* @property {string} cyan
* @property {string} white
* @property {string} lightgrey
* @property {string} darkgrey
* @property {string} grey
* @property {string} dimgrey
*/
/**
* @type {Theme} theme
* A collection of colors to be used by the overlay.
* Partially adopted from Tomorrow Night Bright.
*/
const theme = {
reset: ['transparent', 'transparent'],
black: '000000',
red: 'D34F56',
green: 'B9C954',
yellow: 'E6C452',
blue: '7CA7D8',
magenta: 'C299D6',
cyan: '73BFB1',
white: 'FFFFFF',
lightgrey: 'C7C7C7',
darkgrey: 'A9A9A9',
grey: '474747',
dimgrey: '343434',
};
module.exports = theme;

View file

@ -0,0 +1,30 @@
/**
* Debounce a function to delay invoking until wait (ms) have elapsed since the last invocation.
* @param {function(...*): *} fn The function to be debounced.
* @param {number} wait Milliseconds to wait before invoking again.
* @return {function(...*): void} The debounced function.
*/
function debounce(fn, wait) {
/**
* A cached setTimeout handler.
* @type {number | undefined}
*/
let timer;
/**
* @returns {void}
*/
function debounced() {
const context = this;
const args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
return fn.apply(context, args);
}, wait);
}
return debounced;
}
module.exports = debounce;

View file

@ -0,0 +1,23 @@
/**
* Prettify a filename from error stacks into the desired format.
* @param {string} filename The filename to be formatted.
* @returns {string} The formatted filename.
*/
function formatFilename(filename) {
// Strip away protocol and domain for compiled files
const htmlMatch = /^https?:\/\/(.*)\/(.*)/.exec(filename);
if (htmlMatch && htmlMatch[1] && htmlMatch[2]) {
return htmlMatch[2];
}
// Strip everything before the first directory for source files
const sourceMatch = /\/.*?([^./]+[/|\\].*)$/.exec(filename);
if (sourceMatch && sourceMatch[1]) {
return sourceMatch[1].replace(/\?$/, '');
}
// Unknown filename type, use it as is
return filename;
}
module.exports = formatFilename;

View file

@ -0,0 +1,19 @@
/**
* Remove all children of an element.
* @param {HTMLElement} element A valid HTML element.
* @param {number} [skip] Number of elements to skip removing.
* @returns {void}
*/
function removeAllChildren(element, skip) {
/** @type {Node[]} */
const childList = Array.prototype.slice.call(
element.childNodes,
typeof skip !== 'undefined' ? skip : 0
);
for (let i = 0; i < childList.length; i += 1) {
element.removeChild(childList[i]);
}
}
module.exports = removeAllChildren;