From f3208d3db86f804682b88f67cf7a3e57395dbf33 Mon Sep 17 00:00:00 2001 From: Alan Pierce Date: Sun, 2 Apr 2023 23:39:24 -0700 Subject: [PATCH] Add source map debugging to website Progress toward #541, prep work to help make #759 (and any future source map work) more manually testable. This PR splits out token information into a new collapased-by-default debug section, then adds source map info to that section. The source map toggle does three things: * Show the actual mappings from the source map by line/column index. * Show the JSON value of the source map. * Extend the normal code output to include an inline source map with the full original source embedded, then link to the very helpful tool https://evanw.github.io/source-map-visualization/ , which accepts pasting the output code. --- website/package.json | 1 + website/src/App.tsx | 56 +++++++++++++----- website/src/CompareOptionsBox.tsx | 49 ++++++++++++++++ website/src/Constants.ts | 12 +++- website/src/DebugOptionsBox.tsx | 70 +++++++++++++++++++++++ website/src/DisplayOptionsBox.tsx | 56 ------------------ website/src/URLHashState.ts | 33 ++++++++--- website/src/WorkerClient.ts | 20 ++++--- website/src/WorkerProtocol.ts | 6 +- website/src/{ => worker}/Babel.ts | 0 website/src/{ => worker}/Worker.worker.ts | 35 ++++++++++-- website/src/worker/getSourceMapInfo.ts | 41 +++++++++++++ website/src/{ => worker}/getTokens.ts | 0 13 files changed, 282 insertions(+), 97 deletions(-) create mode 100644 website/src/CompareOptionsBox.tsx create mode 100644 website/src/DebugOptionsBox.tsx delete mode 100644 website/src/DisplayOptionsBox.tsx rename website/src/{ => worker}/Babel.ts (100%) rename website/src/{ => worker}/Worker.worker.ts (87%) create mode 100644 website/src/worker/getSourceMapInfo.ts rename website/src/{ => worker}/getTokens.ts (100%) diff --git a/website/package.json b/website/package.json index 78522413..2e598496 100644 --- a/website/package.json +++ b/website/package.json @@ -5,6 +5,7 @@ "homepage": "https://sucrase.io", "dependencies": { "@babel/standalone": "^7.18.5", + "@jridgewell/trace-mapping": "^0.3.17", "@sucrase/webpack-loader": "^2.0.0", "@types/base64-js": "^1.2.5", "@types/gzip-js": "^0.3.1", diff --git a/website/src/App.tsx b/website/src/App.tsx index 6f0a1f0d..8ac3e3a4 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -2,14 +2,17 @@ import {css, StyleSheet} from "aphrodite"; import {Component} from "react"; import {hot} from "react-hot-loader/root"; +import CompareOptionsBox from "./CompareOptionsBox"; import { - DEFAULT_DISPLAY_OPTIONS, + DebugOptions, + DEFAULT_DEBUG_OPTIONS, + DEFAULT_COMPARE_OPTIONS, DEFAULT_OPTIONS, - DisplayOptions, + CompareOptions, HydratedOptions, INITIAL_CODE, } from "./Constants"; -import DisplayOptionsBox from "./DisplayOptionsBox"; +import DebugOptionsBox from "./DebugOptionsBox"; import EditorWrapper from "./EditorWrapper"; import SucraseOptionsBox from "./SucraseOptionsBox"; import {loadHashState, saveHashState} from "./URLHashState"; @@ -17,8 +20,9 @@ import * as WorkerClient from "./WorkerClient"; interface State { code: string; - displayOptions: DisplayOptions; sucraseOptions: HydratedOptions; + compareOptions: CompareOptions; + debugOptions: DebugOptions; sucraseCode: string; sucraseTimeMs: number | null | "LOADING"; babelCode: string; @@ -26,6 +30,7 @@ interface State { typeScriptCode: string; typeScriptTimeMs: number | null | "LOADING"; tokensStr: string; + sourceMapStr: string; showMore: boolean; babelLoaded: boolean; typeScriptLoaded: boolean; @@ -36,8 +41,9 @@ class App extends Component { super(props); this.state = { code: INITIAL_CODE, - displayOptions: DEFAULT_DISPLAY_OPTIONS, sucraseOptions: DEFAULT_OPTIONS, + compareOptions: DEFAULT_COMPARE_OPTIONS, + debugOptions: DEFAULT_DEBUG_OPTIONS, sucraseCode: "", sucraseTimeMs: null, babelCode: "", @@ -45,6 +51,7 @@ class App extends Component { typeScriptCode: "", typeScriptTimeMs: null, tokensStr: "", + sourceMapStr: "", showMore: false, babelLoaded: false, typeScriptLoaded: false, @@ -65,7 +72,8 @@ class App extends Component { code: this.state.code, compressedCode, sucraseOptions: this.state.sucraseOptions, - displayOptions: this.state.displayOptions, + compareOptions: this.state.compareOptions, + debugOptions: this.state.debugOptions, }); }, }); @@ -76,7 +84,8 @@ class App extends Component { if ( this.state.code !== prevState.code || this.state.sucraseOptions !== prevState.sucraseOptions || - this.state.displayOptions !== prevState.displayOptions || + this.state.compareOptions !== prevState.compareOptions || + this.state.debugOptions !== prevState.debugOptions || this.state.babelLoaded !== prevState.babelLoaded || this.state.typeScriptLoaded !== prevState.typeScriptLoaded ) { @@ -89,7 +98,8 @@ class App extends Component { WorkerClient.updateConfig({ code: this.state.code, sucraseOptions: this.state.sucraseOptions, - displayOptions: this.state.displayOptions, + compareOptions: this.state.compareOptions, + debugOptions: this.state.debugOptions, }); } @@ -108,6 +118,7 @@ class App extends Component { typeScriptCode, typeScriptTimeMs, tokensStr, + sourceMapStr, } = this.state; return (
@@ -126,10 +137,16 @@ class App extends Component { this.setState({sucraseOptions}); }} /> - { - this.setState({displayOptions}); + { + this.setState({compareOptions}); + }} + /> + { + this.setState({debugOptions}); }} />
@@ -148,7 +165,7 @@ class App extends Component { isReadOnly={true} babelLoaded={this.state.babelLoaded} /> - {this.state.displayOptions.compareWithBabel && ( + {this.state.compareOptions.compareWithBabel && ( { babelLoaded={this.state.babelLoaded} /> )} - {this.state.displayOptions.compareWithTypeScript && ( + {this.state.compareOptions.compareWithTypeScript && ( { babelLoaded={this.state.babelLoaded} /> )} - {this.state.displayOptions.showTokens && ( + {this.state.debugOptions.showTokens && ( { babelLoaded={this.state.babelLoaded} /> )} + {this.state.debugOptions.showSourceMap && ( + + )} diff --git a/website/src/CompareOptionsBox.tsx b/website/src/CompareOptionsBox.tsx new file mode 100644 index 00000000..15498531 --- /dev/null +++ b/website/src/CompareOptionsBox.tsx @@ -0,0 +1,49 @@ +import {css, StyleSheet} from "aphrodite"; + +import CheckBox from "./CheckBox"; +import type {CompareOptions} from "./Constants"; +import OptionsBox from "./OptionsBox"; + +interface CompareOptionsBoxProps { + compareOptions: CompareOptions; + onUpdateCompareOptions: (compareOptions: CompareOptions) => void; +} + +export default function CompareOptionsBox({ + compareOptions, + onUpdateCompareOptions, +}: CompareOptionsBoxProps): JSX.Element { + return ( + +
+ Compare + { + onUpdateCompareOptions({...compareOptions, compareWithBabel: checked}); + }} + /> + { + onUpdateCompareOptions({...compareOptions, compareWithTypeScript: checked}); + }} + /> +
+
+ ); +} + +const styles = StyleSheet.create({ + optionBox: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + }, + title: { + fontSize: "1.2em", + padding: 6, + }, +}); diff --git a/website/src/Constants.ts b/website/src/Constants.ts index a515973a..58ae205e 100644 --- a/website/src/Constants.ts +++ b/website/src/Constants.ts @@ -65,14 +65,22 @@ export const DEFAULT_OPTIONS: HydratedOptions = { enableLegacyBabel5ModuleInterop: false, }; -export interface DisplayOptions { +export interface CompareOptions { compareWithBabel: boolean; compareWithTypeScript: boolean; +} + +export interface DebugOptions { showTokens: boolean; + showSourceMap: boolean; } -export const DEFAULT_DISPLAY_OPTIONS: DisplayOptions = { +export const DEFAULT_COMPARE_OPTIONS: CompareOptions = { compareWithBabel: true, compareWithTypeScript: false, +}; + +export const DEFAULT_DEBUG_OPTIONS: DebugOptions = { showTokens: false, + showSourceMap: false, }; diff --git a/website/src/DebugOptionsBox.tsx b/website/src/DebugOptionsBox.tsx new file mode 100644 index 00000000..273d30c9 --- /dev/null +++ b/website/src/DebugOptionsBox.tsx @@ -0,0 +1,70 @@ +import {css, StyleSheet} from "aphrodite"; +import {useState} from "react"; + +import CheckBox from "./CheckBox"; +import type {DebugOptions} from "./Constants"; +import OptionsBox from "./OptionsBox"; + +interface DebugOptionsBoxProps { + debugOptions: DebugOptions; + onUpdateDebugOptions: (debugOptions: DebugOptions) => void; +} + +export default function DebugOptionsBox({ + debugOptions, + onUpdateDebugOptions, +}: DebugOptionsBoxProps): JSX.Element { + const [enabled, setEnabled] = useState(false); + if (!enabled) { + return ( +
{ + setEnabled(true); + e.preventDefault(); + }} + > + Debug... + + ); + } + return ( + +
+ Debug + { + onUpdateDebugOptions({...debugOptions, showTokens: checked}); + }} + /> + { + onUpdateDebugOptions({...debugOptions, showSourceMap: checked}); + }} + /> +
+
+ ); +} + +const styles = StyleSheet.create({ + optionBox: { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + }, + link: { + color: "#CCCCCC", + marginLeft: 6, + marginRight: 6, + }, + title: { + fontSize: "1.2em", + padding: 6, + }, +}); diff --git a/website/src/DisplayOptionsBox.tsx b/website/src/DisplayOptionsBox.tsx deleted file mode 100644 index d0fed166..00000000 --- a/website/src/DisplayOptionsBox.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import {css, StyleSheet} from "aphrodite"; - -import CheckBox from "./CheckBox"; -import type {DisplayOptions} from "./Constants"; -import OptionsBox from "./OptionsBox"; - -interface DisplayOptionsBoxProps { - displayOptions: DisplayOptions; - onUpdateDisplayOptions: (displayOptions: DisplayOptions) => void; -} - -export default function DisplayOptionsBox({ - displayOptions, - onUpdateDisplayOptions, -}: DisplayOptionsBoxProps): JSX.Element { - return ( - -
- Compare - { - onUpdateDisplayOptions({...displayOptions, compareWithBabel: checked}); - }} - /> - { - onUpdateDisplayOptions({...displayOptions, compareWithTypeScript: checked}); - }} - /> - { - onUpdateDisplayOptions({...displayOptions, showTokens: checked}); - }} - /> -
-
- ); -} - -const styles = StyleSheet.create({ - optionBox: { - display: "flex", - flexWrap: "wrap", - alignItems: "center", - }, - title: { - fontSize: "1.2em", - padding: 6, - }, -}); diff --git a/website/src/URLHashState.ts b/website/src/URLHashState.ts index 07b1ae6b..1d5e761c 100644 --- a/website/src/URLHashState.ts +++ b/website/src/URLHashState.ts @@ -4,9 +4,11 @@ import {produce} from "immer"; import type {Transform} from "sucrase"; import { - DEFAULT_DISPLAY_OPTIONS, + DebugOptions, + DEFAULT_DEBUG_OPTIONS, + DEFAULT_COMPARE_OPTIONS, DEFAULT_OPTIONS, - DisplayOptions, + CompareOptions, HydratedOptions, INITIAL_CODE, } from "./Constants"; @@ -14,7 +16,8 @@ import { interface BaseHashState { code: string; sucraseOptions: HydratedOptions; - displayOptions: DisplayOptions; + compareOptions: CompareOptions; + debugOptions: DebugOptions; } type HashState = BaseHashState & {compressedCode: string}; @@ -23,7 +26,8 @@ export function saveHashState({ code, compressedCode, sucraseOptions, - displayOptions, + compareOptions, + debugOptions, }: HashState): void { const components = []; @@ -36,8 +40,14 @@ export function saveHashState({ components.push(`${key}=${encodeURIComponent(formattedValue)}`); } } - for (const [key, defaultValue] of Object.entries(DEFAULT_DISPLAY_OPTIONS)) { - const value = displayOptions[key]; + for (const [key, defaultValue] of Object.entries(DEFAULT_COMPARE_OPTIONS)) { + const value = compareOptions[key]; + if (value !== defaultValue) { + components.push(`${key}=${value}`); + } + } + for (const [key, defaultValue] of Object.entries(DEFAULT_DEBUG_OPTIONS)) { + const value = debugOptions[key]; if (value !== defaultValue) { components.push(`${key}=${value}`); } @@ -72,7 +82,8 @@ export function loadHashState(): BaseHashState | null { let result: BaseHashState = { code: INITIAL_CODE, sucraseOptions: DEFAULT_OPTIONS, - displayOptions: DEFAULT_DISPLAY_OPTIONS, + compareOptions: DEFAULT_COMPARE_OPTIONS, + debugOptions: DEFAULT_DEBUG_OPTIONS, }; for (const component of components) { const [key, value] = component.split("="); @@ -93,9 +104,13 @@ export function loadHashState(): BaseHashState | null { result = produce(result, (draft) => { draft.code = decompressCode(decodeURIComponent(value)); }); - } else if (Object.prototype.hasOwnProperty.call(DEFAULT_DISPLAY_OPTIONS, key)) { + } else if (Object.prototype.hasOwnProperty.call(DEFAULT_COMPARE_OPTIONS, key)) { + result = produce(result, (draft) => { + draft.compareOptions[key] = value === "true"; + }); + } else if (Object.prototype.hasOwnProperty.call(DEFAULT_DEBUG_OPTIONS, key)) { result = produce(result, (draft) => { - draft.displayOptions[key] = value === "true"; + draft.debugOptions[key] = value === "true"; }); } } diff --git a/website/src/WorkerClient.ts b/website/src/WorkerClient.ts index f88e4a9b..68413a31 100644 --- a/website/src/WorkerClient.ts +++ b/website/src/WorkerClient.ts @@ -23,6 +23,7 @@ type UpdateStateFunc = (values: { babelCode?: string; typeScriptCode?: string; tokensStr?: string; + sourceMapStr?: string; sucraseTimeMs?: number | null; babelTimeMs?: number | null; typeScriptTimeMs?: number | null; @@ -41,7 +42,7 @@ type HandleCompressedCodeFunc = (compressedCode: string) => void; let handleCompressedCodeFn: HandleCompressedCodeFunc | null = null; function initWorker(): void { - worker = new Worker(new URL("./Worker.worker", import.meta.url)); + worker = new Worker(new URL("./worker/Worker.worker", import.meta.url)); worker.addEventListener("message", ({data}: {data: WorkerMessage}) => { if (data.type === "RESPONSE") { if (!nextResolve) { @@ -118,6 +119,10 @@ async function getTokens(): Promise { return (await sendMessage({type: "GET_TOKENS"})) as string; } +async function getSourceMap(): Promise { + return (await sendMessage({type: "GET_SOURCE_MAP"})) as string; +} + async function profileSucrase(): Promise { return (await sendMessage({type: "PROFILE_SUCRASE"})) as number; } @@ -184,21 +189,22 @@ async function workerLoop(): Promise { try { await setConfig(config); const sucraseCode = await runSucrase(); - const babelCode = config.displayOptions.compareWithBabel ? await runBabel() : ""; - const typeScriptCode = config.displayOptions.compareWithTypeScript + const babelCode = config.compareOptions.compareWithBabel ? await runBabel() : ""; + const typeScriptCode = config.compareOptions.compareWithTypeScript ? await runTypeScript() : ""; - const tokensStr = config.displayOptions.showTokens ? await getTokens() : ""; - updateStateFn({sucraseCode, babelCode, typeScriptCode, tokensStr}); + const tokensStr = config.debugOptions.showTokens ? await getTokens() : ""; + const sourceMapStr = config.debugOptions.showSourceMap ? await getSourceMap() : ""; + updateStateFn({sucraseCode, babelCode, typeScriptCode, tokensStr, sourceMapStr}); const compressedCode = await compressCode(); handleCompressedCodeFn(compressedCode); const sucraseTimeMs = await profile(profileSucrase); - const babelTimeMs = config.displayOptions.compareWithBabel + const babelTimeMs = config.compareOptions.compareWithBabel ? await profile(profileBabel) : null; - const typeScriptTimeMs = config.displayOptions.compareWithTypeScript + const typeScriptTimeMs = config.compareOptions.compareWithTypeScript ? await profile(profileTypeScript) : null; updateStateFn({sucraseTimeMs, babelTimeMs, typeScriptTimeMs}); diff --git a/website/src/WorkerProtocol.ts b/website/src/WorkerProtocol.ts index 272b70a2..fb1df8bb 100644 --- a/website/src/WorkerProtocol.ts +++ b/website/src/WorkerProtocol.ts @@ -1,6 +1,6 @@ import type {Options} from "sucrase"; -import type {DisplayOptions} from "./Constants"; +import type {CompareOptions, DebugOptions} from "./Constants"; export type Message = | {type: "SET_CONFIG"; config: WorkerConfig} @@ -9,6 +9,7 @@ export type Message = | {type: "RUN_TYPESCRIPT"} | {type: "COMPRESS_CODE"} | {type: "GET_TOKENS"} + | {type: "GET_SOURCE_MAP"} | {type: "PROFILE_SUCRASE"} | {type: "PROFILE_BABEL"} | {type: "PROFILE_TYPESCRIPT"}; @@ -21,5 +22,6 @@ export type WorkerMessage = export interface WorkerConfig { code: string; sucraseOptions: Options; - displayOptions: DisplayOptions; + compareOptions: CompareOptions; + debugOptions: DebugOptions; } diff --git a/website/src/Babel.ts b/website/src/worker/Babel.ts similarity index 100% rename from website/src/Babel.ts rename to website/src/worker/Babel.ts diff --git a/website/src/Worker.worker.ts b/website/src/worker/Worker.worker.ts similarity index 87% rename from website/src/Worker.worker.ts rename to website/src/worker/Worker.worker.ts index 4020e559..aa5efd13 100644 --- a/website/src/Worker.worker.ts +++ b/website/src/worker/Worker.worker.ts @@ -1,10 +1,12 @@ /* eslint-disable no-restricted-globals */ +import * as Base64 from "base64-js"; import * as Sucrase from "sucrase"; import type {JsxEmit, ModuleKind} from "typescript"; +import {compressCode} from "../URLHashState"; +import type {Message, WorkerConfig, WorkerMessage} from "../WorkerProtocol"; +import getSourceMapInfo from "./getSourceMapInfo"; import getTokens from "./getTokens"; -import {compressCode} from "./URLHashState"; -import type {Message, WorkerConfig, WorkerMessage} from "./WorkerProtocol"; declare const self: Worker; @@ -61,6 +63,10 @@ self.addEventListener("message", ({data}) => { // eslint-disable-next-line @typescript-eslint/no-floating-promises loadDependencies(); +function assertNever(value: never): unknown { + return value; +} + function processEvent(data: Message): unknown { if (data.type === "SET_CONFIG") { config = data.config; @@ -75,14 +81,18 @@ function processEvent(data: Message): unknown { return compressCode(config.code); } else if (data.type === "GET_TOKENS") { return getTokens(config.code, config.sucraseOptions); + } else if (data.type === "GET_SOURCE_MAP") { + return getSourceMapInfo(config.code, config.sucraseOptions, getFilePath()); } else if (data.type === "PROFILE_SUCRASE") { return runSucrase().time; } else if (data.type === "PROFILE_BABEL") { return runBabel().time; } else if (data.type === "PROFILE_TYPESCRIPT") { return runTypeScript().time; + } else { + assertNever(data); + return null; } - return null; } function getFilePath(): string { @@ -98,9 +108,22 @@ function getFilePath(): string { } function runSucrase(): {code: string; time: number | null} { - return runAndProfile( - () => Sucrase.transform(config.code, {filePath: getFilePath(), ...config.sucraseOptions}).code, - ); + return runAndProfile(() => { + if (config.debugOptions.showSourceMap) { + const {code: transformedCode, sourceMap} = Sucrase.transform(config.code, { + filePath: getFilePath(), + ...config.sucraseOptions, + sourceMapOptions: {compiledFilename: "sample.js"}, + }); + sourceMap!.sourcesContent = [config.code]; + const mapBase64 = Base64.fromByteArray(new TextEncoder().encode(JSON.stringify(sourceMap))); + const suffix = `//# sourceMappingURL=data:application/json;charset=utf-8;base64,${mapBase64}`; + return `${transformedCode}\n${suffix}`; + } else { + return Sucrase.transform(config.code, {filePath: getFilePath(), ...config.sucraseOptions}) + .code; + } + }); } function runBabel(): {code: string; time: number | null} { diff --git a/website/src/worker/getSourceMapInfo.ts b/website/src/worker/getSourceMapInfo.ts new file mode 100644 index 00000000..f76cb35a --- /dev/null +++ b/website/src/worker/getSourceMapInfo.ts @@ -0,0 +1,41 @@ +import {eachMapping, TraceMap, EncodedSourceMap} from "@jridgewell/trace-mapping"; +import {transform, Options} from "sucrase"; + +/** + * Create a helpful string for debugging source map info. + * + * Currently, this consists of: + * - A list of all mappings, organized by generated line. + * - The JSON value of the source map. + * - A hint to use https://evanw.github.io/source-map-visualization/ for better + * visualization. + */ +export default function getSourceMapInfo(code: string, options: Options, filePath: string): string { + try { + const {sourceMap} = transform(code, { + filePath, + ...options, + sourceMapOptions: {compiledFilename: "sample.js"}, + }); + const traceMap = new TraceMap(sourceMap as EncodedSourceMap); + let currentLine = 1; + let resultText = ""; + eachMapping(traceMap, ({generatedLine, generatedColumn, originalLine, originalColumn}) => { + if (generatedLine === currentLine && currentLine > 1) { + resultText += ", "; + } + while (generatedLine > currentLine) { + resultText += "\n"; + currentLine++; + } + resultText += `${generatedColumn} -> (${originalLine}, ${originalColumn})`; + }); + resultText += `\n\n${JSON.stringify(sourceMap, null, 2)}`; + resultText += `\n\nBetter visualization by pasting transformed code here:\nhttps://evanw.github.io/source-map-visualization/`; + return resultText; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return e.message; + } +} diff --git a/website/src/getTokens.ts b/website/src/worker/getTokens.ts similarity index 100% rename from website/src/getTokens.ts rename to website/src/worker/getTokens.ts