diff --git a/client/modules/IDE/components/Editor/consoleErrorDecoration.js b/client/modules/IDE/components/Editor/consoleErrorDecoration.js index ce3890bcca..5023df978f 100644 --- a/client/modules/IDE/components/Editor/consoleErrorDecoration.js +++ b/client/modules/IDE/components/Editor/consoleErrorDecoration.js @@ -36,6 +36,9 @@ const ERROR_DECORATION = Decoration.line({ // Add an error decoration to a specific line number export function addErrorDecoration(view, lineNumber) { + if (!view || !lineNumber) return; + const totalLines = view.state.doc.lines; + if (lineNumber < 1 || lineNumber > totalLines) return; const docLineNumber = view.state.doc.line(lineNumber); view.dispatch({ effects: ADD_ERROR_DECORATION.of([ @@ -46,6 +49,7 @@ export function addErrorDecoration(view, lineNumber) { // Remove all error decorations export function removeErrorDecorations(view) { + if (!view) return; view.dispatch({ effects: FILTER_ERROR_DECORATION.of(() => false) }); diff --git a/client/modules/IDE/components/Editor/index.jsx b/client/modules/IDE/components/Editor/index.jsx index 23f1c69900..73191729c9 100644 --- a/client/modules/IDE/components/Editor/index.jsx +++ b/client/modules/IDE/components/Editor/index.jsx @@ -148,27 +148,47 @@ function Editor({ useEffect(() => { const consoleErrors = consoleEvents.filter((e) => e.method === 'error'); - if (consoleErrors.length > 0) { - const firstError = consoleErrors[0]; - const errorObj = { stack: firstError.data[0].toString() }; - StackTrace.fromError(errorObj).then((stackLines) => { - expandConsole(); - const line = stackLines.find( - (l) => l.fileName && l.fileName.startsWith('/') + removeErrorDecorations(codemirrorView.current); + + if (consoleErrors.length === 0) return; + + const resolveFrameFromError = (errorEntry) => { + const metaStack = errorEntry.meta && errorEntry.meta.stack; + if (Array.isArray(metaStack) && metaStack.length > 0) { + return Promise.resolve( + metaStack.find((f) => f.fileName && f.fileName.startsWith('/')) || + metaStack[0] ); - if (!line) return; - const fileNameArray = line.fileName.split('/'); - const fileName = fileNameArray.slice(-1)[0]; - const filePath = fileNameArray.slice(0, -1).join('/'); - const fileWithError = files.find( - (f) => f.name === fileName && f.filePath === filePath + } + return StackTrace.fromError({ + stack: errorEntry.data[0].toString() + }).then((stackLines) => + stackLines.find((l) => l.fileName && l.fileName.startsWith('/')) + ); + }; + + const matchFile = (frame) => { + if (!frame || !frame.fileName) return null; + const parts = frame.fileName.split('/'); + const fileName = parts.slice(-1)[0]; + const filePath = parts.slice(0, -1).join('/'); + return files.find((f) => f.name === fileName && f.filePath === filePath); + }; + + Promise.all(consoleErrors.map(resolveFrameFromError)).then((frames) => { + const pairs = frames + .map((frame) => ({ frame, file: matchFile(frame) })) + .filter((p) => p.frame && p.file); + if (pairs.length === 0) return; + expandConsole(); + const targetFileId = pairs[0].file.id; + setSelectedFile(targetFileId); + pairs + .filter((p) => p.file.id === targetFileId) + .forEach((p) => + addErrorDecoration(codemirrorView.current, p.frame.lineNumber) ); - setSelectedFile(fileWithError.id); - addErrorDecoration(codemirrorView.current, line.lineNumber); - }); - } else { - removeErrorDecorations(codemirrorView.current); - } + }); }, [consoleEvents]); const editorSectionClass = classNames({ diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 2b6ac16720..95cfae45a2 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -21,6 +21,8 @@ import resolvePathsForElementsWithAttribute from '../../../server/utils/resolveU let objectUrls = {}; let objectPaths = {}; +let jshintErrors = []; +let jshintErrorKeys = new Set(); const Frame = styled.iframe` min-height: 100%; @@ -56,23 +58,49 @@ function resolveCSSLinksInString(content, files) { return newContent; } -function jsPreprocess(jsText) { +const JSHINT_OPTIONS = { + esversion: 11, + asi: true, + laxbreak: true, + laxcomma: true +}; + +function jsPreprocess(jsText, fileName, lineOffset = 0) { let newContent = jsText; // check the code for js errors before sending it to strip comments // or loops. - JSHINT(newContent); + JSHINT(newContent, JSHINT_OPTIONS); + + const fatal = JSHINT.errors.filter( + (err) => err && (!err.code || err.code.startsWith('E')) + ); - if (JSHINT.errors.length === 0) { + if (fatal.length === 0) { newContent = decomment(newContent, { ignore: /\/\/\s*noprotect/g, space: true }); newContent = loopProtect(newContent); + return newContent; } - return newContent; + fatal.forEach((err) => { + const line = (err.line || 1) + lineOffset; + const key = `${fileName}:${line}:${err.reason}`; + if (jshintErrorKeys.has(key)) return; + jshintErrorKeys.add(key); + jshintErrors.push({ + file: fileName, + line, + character: err.character, + reason: err.reason, + evidence: err.evidence, + code: err.code + }); + }); + return `/* p5 sketch suppressed due to syntax errors in ${fileName}, see console */`; } -function resolveJSLinksInString(content, files) { +function resolveJSLinksInString(content, files, fileName, lineOffset = 0) { let newContent = content; let jsFileStrings = content.match(STRING_REGEX); jsFileStrings = jsFileStrings || []; @@ -98,10 +126,17 @@ function resolveJSLinksInString(content, files) { } }); - return jsPreprocess(newContent); + return jsPreprocess(newContent, fileName, lineOffset); +} + +function getInlineScriptLineOffset(htmlContent, scriptInner) { + if (!htmlContent || !scriptInner) return 0; + const ix = htmlContent.indexOf(scriptInner); + if (ix < 0) return 0; + return htmlContent.substring(0, ix).split('\n').length - 1; } -function resolveScripts(sketchDoc, files) { +function resolveScripts(sketchDoc, files, htmlContent) { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); scriptsInHTMLArray.forEach((script) => { @@ -136,7 +171,12 @@ function resolveScripts(sketchDoc, files) { ) !== null ) { script.setAttribute('crossorigin', ''); - script.innerHTML = resolveJSLinksInString(script.innerHTML, files); // eslint-disable-line + const inlineOffset = getInlineScriptLineOffset( + htmlContent, + script.innerHTML + ); + script.innerHTML = resolveJSLinksInString(script.innerHTML, files, 'index.html', inlineOffset); // eslint-disable-line + script.dataset.inlineOffset = String(inlineOffset); } }); } @@ -175,7 +215,11 @@ function resolveJSAndCSSLinks(files) { files.forEach((file) => { const newFile = { ...file }; if (file.name.match(/.*\.js$/i)) { - newFile.content = resolveJSLinksInString(newFile.content, files); + newFile.content = resolveJSLinksInString( + newFile.content, + files, + file.name + ); } else if (file.name.match(/.*\.css$/i)) { newFile.content = resolveCSSLinksInString(newFile.content, files); } @@ -188,7 +232,8 @@ function addLoopProtect(sketchDoc) { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); scriptsInHTMLArray.forEach((script) => { - script.innerHTML = jsPreprocess(script.innerHTML); // eslint-disable-line + const inlineOffset = parseInt(script.dataset.inlineOffset || '0', 10); + script.innerHTML = jsPreprocess(script.innerHTML, 'index.html', inlineOffset); // eslint-disable-line }); } @@ -197,6 +242,8 @@ function injectLocalFiles(files, htmlFile, options) { let scriptOffs = []; objectUrls = {}; objectPaths = {}; + jshintErrors = []; + jshintErrorKeys = new Set(); const resolvedFiles = resolveJSAndCSSLinks(files); const parser = new DOMParser(); const sketchDoc = parser.parseFromString(htmlFile.content, 'text/html'); @@ -209,7 +256,7 @@ function injectLocalFiles(files, htmlFile, options) { resolvePathsForElementsWithAttribute('href', sketchDoc, resolvedFiles); // should also include background, data, poster, but these are used way less often - resolveScripts(sketchDoc, resolvedFiles); + resolveScripts(sketchDoc, resolvedFiles, htmlFile.content); resolveStyles(sketchDoc, resolvedFiles); if (textOutput || gridOutput) { @@ -241,13 +288,14 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` const sketchDocString = `\n${sketchDoc.documentElement.outerHTML}`; scriptOffs = getAllScriptOffsets(sketchDocString); const consoleErrorsScript = sketchDoc.createElement('script'); + addLoopProtect(sketchDoc); consoleErrorsScript.innerHTML = ` window.offs = ${JSON.stringify(scriptOffs)}; window.objectUrls = ${JSON.stringify(objectUrls)}; window.objectPaths = ${JSON.stringify(objectPaths)}; + window.__jshintErrors = ${JSON.stringify(jshintErrors)}; window.editorOrigin = '${getConfig('EDITOR_URL')}'; `; - addLoopProtect(sketchDoc); sketchDoc.head.prepend(consoleErrorsScript); return `\n${sketchDoc.documentElement.outerHTML}`; diff --git a/client/utils/previewEntry.js b/client/utils/previewEntry.js index 4292e5a83b..d366d8b895 100644 --- a/client/utils/previewEntry.js +++ b/client/utils/previewEntry.js @@ -35,6 +35,121 @@ setInterval(() => { } }, LOGWAIT); +function detectMissingEquals(err) { + const evidence = err.evidence || ''; + const match = /(let|const|var)\s+([a-z_$][\w$]*)\s+(\d|['"`]|true\b|false\b|null\b)/i.exec( + evidence + ); + if (!match) return null; + return { keyword: match[1], name: match[2] }; +} + +function refineJshintReason(err) { + if (/missing semicolon/i.test(err.reason || '')) { + const eq = detectMissingEquals(err); + if (eq) { + return `Missing '=' in ${eq.keyword} declaration of '${eq.name}'`; + } + } + return err.reason; +} + +function friendlyHintForJshint(err) { + const prefix = `[${err.file}, line ${err.line}]`; + const reason = err.reason || ''; + const eq = detectMissingEquals(err); + if (eq && /missing semicolon/i.test(reason)) { + return `${prefix} a '=' is missing between the variable name '${eq.name}' and its value.`; + } + if (/expected an identifier/i.test(reason)) { + return `${prefix} a value or variable name is missing before the symbol here.`; + } + if (/missing semicolon/i.test(reason)) { + return `${prefix} a ';' might be missing at the end of this statement.`; + } + if (/unclosed (string|regular expression)/i.test(reason)) { + return `${prefix} a string or regular expression is not closed with a matching quote or delimiter.`; + } + if (/missing '\)'/i.test(reason)) { + return `${prefix} a closing ')' is missing. check for unbalanced parentheses.`; + } + if (/missing '\}'/i.test(reason)) { + return `${prefix} a closing '}' is missing. check for unbalanced braces.`; + } + if (/unmatched '/i.test(reason)) { + return `${prefix} a bracket, brace, or parenthesis on this line does not have a matching partner.`; + } + if (/unexpected early end/i.test(reason)) { + return `${prefix} the code ended while a block was still open. a '}' or ')' may be missing.`; + } + if ( + /unexpected '?;'?/i.test(reason) || + /unexpected token ';'/i.test(reason) + ) { + return `${prefix} there is an extra or misplaced ';' on this line.`; + } + if ( + /use of const before it was defined|'[a-z_$][\w$]*' was used before it was defined/i.test( + reason + ) + ) { + return `${prefix} a name is being used before it is declared.`; + } + return `${prefix} ${reason}`; +} + +if (Array.isArray(window.__jshintErrors) && window.__jshintErrors.length > 0) { + const messagesBatch = []; + window.__jshintErrors.forEach((err) => { + const refinedReason = refineJshintReason(err); + const location = `${err.file}:${err.line}:${err.character}`; + const data = `SyntaxError: ${refinedReason} at ${location}`; + const id = `${Date.now()}-${err.file}-${err.line}-${err.character}`; + messagesBatch.push({ + log: [ + { + method: 'error', + data: [data], + id, + meta: { + name: 'SyntaxError', + message: refinedReason, + stack: [ + { + fileName: err.file, + functionName: null, + lineNumber: err.line, + columnNumber: err.character + } + ] + } + } + ] + }); + }); + const mdn = + 'https://developer.mozilla.org/docs/Web/JavaScript/Reference/Errors/Unexpected_token#What_went_wrong'; + const hintLines = window.__jshintErrors + .map((err) => friendlyHintForJshint(err)) + .join('\n'); + messagesBatch.push({ + log: [ + { + method: 'log', + data: [`🌸 p5.js says:\n${hintLines}\n\n+ More info: ${mdn}`], + id: `${Date.now()}-jshint-friendly` + } + ] + }); + editor.postMessage( + { + source: 'sketch', + messages: messagesBatch + }, + editorOrigin + ); +} + function handleMessageEvent(e) { // maybe don't need this?? idk! if (window.origin !== e.origin) return; @@ -61,90 +176,133 @@ function handleMessageEvent(e) { window.addEventListener('message', handleMessageEvent); -// catch reference errors, via http://stackoverflow.com/a/12747364/2994108 -window.onerror = async function onError( - msg, - source, +function resolveFileName(rawName) { + if (!rawName) return rawName; + if (window.objectUrls[rawName]) return window.objectUrls[rawName]; + const withBlob = `blob:${rawName}`; + if (window.objectUrls[withBlob]) return window.objectUrls[withBlob]; + const segment = rawName.split('/').pop(); + if (window.objectPaths[segment]) return window.objectPaths[segment]; + return rawName; +} + +function resolveStackFrame({ + fileName, + functionName, lineNumber, - columnNo, - error -) { - // maybe i can use error.stack sometime but i'm having a hard time triggering - // this function - let data; - if (!error) { - data = msg; - } else { - data = `${error.name}: ${error.message}`; - const resolvedFileName = window.objectUrls[source]; - let resolvedLineNo = lineNumber; - if (window.objectUrls[source] === 'index.html') { - resolvedLineNo = lineNumber - htmlOffset; - } - const line = `\n at ${resolvedFileName}:${resolvedLineNo}:${columnNo}`; - data = data.concat(line); + columnNumber +}) { + const resolvedFileName = resolveFileName(fileName); + let resolvedLineNumber = lineNumber; + const isIndexHtml = + resolvedFileName === 'index.html' || resolvedFileName === '/index.html'; + if (isIndexHtml && lineNumber) { + resolvedLineNumber = lineNumber - htmlOffset; + } + return { + fileName: resolvedFileName, + functionName: functionName || null, + lineNumber: resolvedLineNumber || null, + columnNumber: columnNumber || null + }; +} + +function formatStackFrame({ + fileName, + functionName, + lineNumber, + columnNumber +}) { + const name = functionName || '(anonymous function)'; + if (lineNumber && columnNumber) { + return `\n at ${name} (${fileName}:${lineNumber}:${columnNumber})`; } + return `\n at ${name} (${fileName})`; +} + +function postErrorMessage(data, meta) { + const log = { + method: 'error', + data: [data], + id: Date.now().toString() + }; + if (meta) log.meta = meta; editor.postMessage( { source: 'sketch', messages: [ { - log: [ - { - method: 'error', - data: [data], - id: Date.now().toString() - } - ] + log: [log] } ] }, editorOrigin ); +} + +// catch reference errors, via http://stackoverflow.com/a/12747364/2994108 +window.onerror = async function onError( + msg, + source, + lineNumber, + columnNo, + error +) { + if (!error) { + postErrorMessage(msg); + return false; + } + let rawStack = []; + if (error.stack) { + try { + rawStack = await StackTrace.fromError(error); + } catch (e) { + rawStack = []; + } + } + if (rawStack.length === 0) { + rawStack = [ + { + fileName: source, + functionName: null, + lineNumber, + columnNumber: columnNo + } + ]; + } + const resolvedStack = rawStack.map(resolveStackFrame); + let data = `${error.name}: ${error.message}`; + resolvedStack.forEach((frame) => { + data = data.concat(formatStackFrame(frame)); + }); + postErrorMessage(data, { + name: error.name, + message: error.message, + stack: resolvedStack + }); return false; }; // catch rejected promises window.onunhandledrejection = async function onUnhandledRejection(event) { - if (event.reason && event.reason.message) { - let stackLines = []; - if (event.reason.stack) { - stackLines = await StackTrace.fromError(event.reason); + if (!event.reason || !event.reason.message) return; + let rawStack = []; + if (event.reason.stack) { + try { + rawStack = await StackTrace.fromError(event.reason); + } catch (e) { + rawStack = []; } - let data = `${event.reason.name}: ${event.reason.message}`; - stackLines.forEach((stackLine) => { - const { fileName, functionName, lineNumber, columnNumber } = stackLine; - const resolvedFileName = window.objectUrls[fileName] || fileName; - const resolvedFuncName = functionName || '(anonymous function)'; - let line; - if (lineNumber && columnNumber) { - let resolvedLineNumber = lineNumber; - if (resolvedFileName === 'index.html') { - resolvedLineNumber = lineNumber - htmlOffset; - } - line = `\n at ${resolvedFuncName} (${resolvedFileName}:${resolvedLineNumber}:${columnNumber})`; - } else { - line = `\n at ${resolvedFuncName} (${resolvedFileName})`; - } - data = data.concat(line); - }); - editor.postMessage( - { - source: 'sketch', - messages: [ - { - log: [ - { - method: 'error', - data: [data], - id: Date.now().toString() - } - ] - } - ] - }, - editorOrigin - ); } + const resolvedStack = rawStack.map(resolveStackFrame); + let data = `${event.reason.name}: ${event.reason.message}`; + resolvedStack.forEach((frame) => { + data = data.concat(formatStackFrame(frame)); + }); + postErrorMessage(data, { + name: event.reason.name, + message: event.reason.message, + stack: resolvedStack + }); }; // Monkeypatch p5._friendlyError