Skip to content

Commit

Permalink
Refactor website code to use full Sucrase options (#735)
Browse files Browse the repository at this point in the history
This is some preparatory work to pass around the full Sucrase options object
internally in the website code, rather than just the list of transforms. This
will make it easier to add a UI for specifying the other options in the future.

Some notes:
* For now, the URL format is URL-encoded JSON, which doesn't look as nice as
  before but should be future-proof. I kept compatibility with the existing
  `selectedTransforms` option to avoid breaking old URLs.
* Rather than the transforms object specifying how the transforms map to Babel
  plugins/presets, the Babel and TypeScript wrappers are responsible for taking
  plain Sucrase options and matching them as closely as possible.
* I kept the transform order canonical so that string-based JSON comparisons
  still work to see if the config is the default one.
  • Loading branch information
alangpierce authored Aug 9, 2022
1 parent c7d7c7c commit dac0d7d
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 95 deletions.
38 changes: 22 additions & 16 deletions website/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {css, StyleSheet} from "aphrodite";
import React, {Component} from "react";
import {hot} from "react-hot-loader/root";
import type {Options} from "sucrase";

import {
DEFAULT_COMPARE_WITH_BABEL,
DEFAULT_COMPARE_WITH_TYPESCRIPT,
DEFAULT_OPTIONS,
DEFAULT_SHOW_TOKENS,
DEFAULT_TRANSFORMS,
INITIAL_CODE,
TRANSFORMS,
} from "./Constants";
Expand All @@ -20,7 +21,7 @@ interface State {
compareWithBabel: boolean;
compareWithTypeScript: boolean;
showTokens: boolean;
selectedTransforms: {[transformName: string]: boolean};
sucraseOptions: Options;
sucraseCode: string;
sucraseTimeMs: number | null | "LOADING";
babelCode: string;
Expand All @@ -41,8 +42,7 @@ class App extends Component<unknown, State> {
compareWithBabel: DEFAULT_COMPARE_WITH_BABEL,
compareWithTypeScript: DEFAULT_COMPARE_WITH_TYPESCRIPT,
showTokens: DEFAULT_SHOW_TOKENS,
// Object with a true value for any selected transform keys.
selectedTransforms: DEFAULT_TRANSFORMS.reduce((o, name) => ({...o, [name]: true}), {}),
sucraseOptions: DEFAULT_OPTIONS,
sucraseCode: "",
sucraseTimeMs: null,
babelCode: "",
Expand Down Expand Up @@ -79,7 +79,7 @@ class App extends Component<unknown, State> {
saveHashState({
code: this.state.code,
compressedCode,
selectedTransforms: this.state.selectedTransforms,
sucraseOptions: this.state.sucraseOptions,
compareWithBabel: this.state.compareWithBabel,
compareWithTypeScript: this.state.compareWithTypeScript,
showTokens: this.state.showTokens,
Expand All @@ -92,7 +92,7 @@ class App extends Component<unknown, State> {
componentDidUpdate(prevProps: unknown, prevState: State): void {
if (
this.state.code !== prevState.code ||
this.state.selectedTransforms !== prevState.selectedTransforms ||
this.state.sucraseOptions !== prevState.sucraseOptions ||
this.state.compareWithBabel !== prevState.compareWithBabel ||
this.state.compareWithTypeScript !== prevState.compareWithTypeScript ||
this.state.showTokens !== prevState.showTokens ||
Expand All @@ -109,7 +109,7 @@ class App extends Component<unknown, State> {
compareWithBabel: this.state.compareWithBabel,
compareWithTypeScript: this.state.compareWithTypeScript,
code: this.state.code,
selectedTransforms: this.state.selectedTransforms,
sucraseOptions: this.state.sucraseOptions,
showTokens: this.state.showTokens,
});
}
Expand Down Expand Up @@ -155,21 +155,27 @@ class App extends Component<unknown, State> {
<div className={css(styles.options)}>
<OptionBox
title="Transforms"
options={TRANSFORMS.map(({name}) => ({
options={TRANSFORMS.map((name) => ({
text: name,
checked: Boolean(this.state.selectedTransforms[name]),
checked: this.state.sucraseOptions.transforms.includes(name),
onToggle: () => {
let newTransforms = this.state.selectedTransforms;
newTransforms = {...newTransforms, [name]: !newTransforms[name]};
// Don't allow typescript and flow at the same time.
if (newTransforms.typescript && newTransforms.flow) {
let newTransforms = [...this.state.sucraseOptions.transforms];
if (newTransforms.includes(name)) {
newTransforms = newTransforms.filter((t) => t !== name);
} else {
newTransforms.push(name);
// TypeScript and Flow are mutually exclusive, so enabling one disables the other.
if (name === "typescript") {
newTransforms = {...newTransforms, flow: false};
newTransforms = newTransforms.filter((t) => t !== "flow");
} else if (name === "flow") {
newTransforms = {...newTransforms, typescript: false};
newTransforms = newTransforms.filter((t) => t !== "typescript");
}
}
this.setState({selectedTransforms: newTransforms});
// Keep the order canonical for easy comparison.
newTransforms.sort((t1, t2) => TRANSFORMS.indexOf(t1) - TRANSFORMS.indexOf(t2));
this.setState({
sucraseOptions: {...this.state.sucraseOptions, transforms: newTransforms},
});
},
}))}
/>
Expand Down
26 changes: 11 additions & 15 deletions website/src/Constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Transform} from "sucrase";
import type {Options, Transform} from "sucrase";

export const INITIAL_CODE = `\
// Try typing or pasting some code into the left editor!
Expand Down Expand Up @@ -30,22 +30,18 @@ export default App;
`;

interface TransformInfo {
name: Transform;
presetName?: unknown;
babelName?: string;
}

export const TRANSFORMS: Array<TransformInfo> = [
{name: "jsx", presetName: ["react", {development: true}]},
{name: "typescript", presetName: ["typescript", {allowDeclareFields: true}]},
{name: "flow", presetName: "flow", babelName: "transform-flow-enums"},
{name: "imports", babelName: "transform-modules-commonjs"},
{name: "react-hot-loader", babelName: "react-hot-loader"},
{name: "jest", babelName: "jest-hoist"},
export const TRANSFORMS: Array<Transform> = [
"jsx",
"typescript",
"flow",
"imports",
"react-hot-loader",
"jest",
];

export const DEFAULT_TRANSFORMS = ["jsx", "typescript", "imports"];
export const DEFAULT_OPTIONS: Options = {
transforms: ["jsx", "typescript", "imports"],
};
export const DEFAULT_COMPARE_WITH_BABEL = true;
export const DEFAULT_COMPARE_WITH_TYPESCRIPT = false;
export const DEFAULT_SHOW_TOKENS = false;
4 changes: 2 additions & 2 deletions website/src/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {editor} from "monaco-editor";
import type {editor} from "monaco-editor";
import React, {Component} from "react";
import {EditorDidMount} from "react-monaco-editor";
import type {EditorDidMount} from "react-monaco-editor";

interface EditorProps {
MonacoEditor: typeof import("react-monaco-editor").default;
Expand Down
2 changes: 1 addition & 1 deletion website/src/EditorWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {css, StyleSheet} from "aphrodite";
import {editor} from "monaco-editor";
import type {editor} from "monaco-editor";
import React, {Component} from "react";
import AutoSizer from "react-virtualized-auto-sizer";

Expand Down
28 changes: 13 additions & 15 deletions website/src/URLHashState.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import * as Base64 from "base64-js";
import GZip from "gzip-js";
import type {Options, Transform} from "sucrase";

import {
DEFAULT_COMPARE_WITH_BABEL,
DEFAULT_COMPARE_WITH_TYPESCRIPT,
DEFAULT_OPTIONS,
DEFAULT_SHOW_TOKENS,
DEFAULT_TRANSFORMS,
INITIAL_CODE,
TRANSFORMS,
} from "./Constants";

interface BaseHashState {
code: string;
selectedTransforms: {[transformName: string]: boolean};
sucraseOptions: Options;
compareWithBabel: boolean;
compareWithTypeScript: boolean;
showTokens: boolean;
Expand All @@ -23,19 +23,16 @@ type HashState = BaseHashState & {compressedCode: string};
export function saveHashState({
code,
compressedCode,
selectedTransforms,
sucraseOptions,
compareWithBabel,
compareWithTypeScript,
showTokens,
}: HashState): void {
const components = [];

const transformsValue = TRANSFORMS.filter(({name}) => selectedTransforms[name])
.map(({name}) => name)
.join(",");

if (transformsValue !== DEFAULT_TRANSFORMS.join(",")) {
components.push(`selectedTransforms=${transformsValue}`);
const serializedSucraseOptions = JSON.stringify(sucraseOptions);
if (serializedSucraseOptions !== JSON.stringify(DEFAULT_OPTIONS)) {
components.push(`sucraseOptions=${encodeURIComponent(serializedSucraseOptions)}`);
}
if (compareWithBabel !== DEFAULT_COMPARE_WITH_BABEL) {
components.push(`compareWithBabel=${compareWithBabel}`);
Expand Down Expand Up @@ -76,11 +73,12 @@ export function loadHashState(): Partial<BaseHashState> | null {
const result: Partial<HashState> = {};
for (const component of components) {
const [key, value] = component.split("=");
if (key === "selectedTransforms") {
result.selectedTransforms = {};
for (const transformName of value.split(",")) {
result.selectedTransforms[transformName] = true;
}
if (key === "sucraseOptions") {
result.sucraseOptions = JSON.parse(decodeURIComponent(value));
} else if (key === "selectedTransforms") {
// Old URLs may have selectedTransforms from before the format switched
// to sucraseOptions.
result.sucraseOptions = {transforms: value.split(",") as Array<Transform>};
} else if (key === "code") {
result.code = decodeURIComponent(value);
} else if (key === "compressedCode") {
Expand Down
92 changes: 52 additions & 40 deletions website/src/Worker.worker.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
/* eslint-disable no-restricted-globals */
import * as Sucrase from "sucrase";

import {TRANSFORMS} from "./Constants";
import getTokens from "./getTokens";
import {compressCode} from "./URLHashState";
import {Message, WorkerConfig, WorkerMessage} from "./WorkerProtocol";

type Transform = Sucrase.Transform;
import type {Message, WorkerConfig, WorkerMessage} from "./WorkerProtocol";

declare const self: Worker;

Expand Down Expand Up @@ -76,7 +73,7 @@ function processEvent(data: Message): unknown {
} else if (data.type === "COMPRESS_CODE") {
return compressCode(config.code);
} else if (data.type === "GET_TOKENS") {
return getTokens(config.code, getSelectedTransforms());
return getTokens(config.code, config.sucraseOptions);
} else if (data.type === "PROFILE_SUCRASE") {
return runSucrase().time;
} else if (data.type === "PROFILE_BABEL") {
Expand All @@ -87,13 +84,9 @@ function processEvent(data: Message): unknown {
return null;
}

function getSelectedTransforms(): Array<Transform> {
return TRANSFORMS.map(({name}) => name).filter((name) => config.selectedTransforms[name]);
}

function getFilePath(): string {
if (config.selectedTransforms.typescript) {
if (config.selectedTransforms.jsx) {
if (config.sucraseOptions.transforms.includes("typescript")) {
if (config.sucraseOptions.transforms.includes("jsx")) {
return "sample.tsx";
} else {
return "sample.ts";
Expand All @@ -105,9 +98,7 @@ function getFilePath(): string {

function runSucrase(): {code: string; time: number | null} {
return runAndProfile(
() =>
Sucrase.transform(config.code, {transforms: getSelectedTransforms(), filePath: getFilePath()})
.code,
() => Sucrase.transform(config.code, {filePath: getFilePath(), ...config.sucraseOptions}).code,
);
}

Expand All @@ -116,26 +107,46 @@ function runBabel(): {code: string; time: number | null} {
return {code: "Loading Babel...", time: null};
}
const {transform} = Babel;
const babelPlugins = TRANSFORMS.filter(({name}) => config.selectedTransforms[name])
.map(({babelName}) => babelName)
.filter((name) => name);
const babelPresets = TRANSFORMS.filter(({name}) => config.selectedTransforms[name])
.map(({presetName}) => presetName)
.filter((name) => name);
const {sucraseOptions} = config;

const plugins: Array<string> = [];
const presets: Array<string | [string, unknown]> = [];

if (sucraseOptions.transforms.includes("jsx")) {
presets.push(["react", {development: !sucraseOptions.production}]);
}
if (sucraseOptions.transforms.includes("typescript")) {
presets.push(["typescript", {allowDeclareFields: true}]);
}
if (sucraseOptions.transforms.includes("flow")) {
presets.push("flow");
plugins.push("transform-flow-enums");
}
if (sucraseOptions.transforms.includes("imports")) {
plugins.push("transform-modules-commonjs");
}
if (sucraseOptions.transforms.includes("react-hot-loader")) {
plugins.push("react-hot-loader");
}
if (sucraseOptions.transforms.includes("jest")) {
plugins.push("jest-hoist");
}

plugins.push(
"proposal-export-namespace-from",
"proposal-numeric-separator",
"proposal-optional-catch-binding",
"proposal-nullish-coalescing-operator",
"proposal-optional-chaining",
"dynamic-import-node",
);

return runAndProfile(
() =>
transform(config.code, {
filename: getFilePath(),
presets: babelPresets,
plugins: [
...babelPlugins,
"proposal-export-namespace-from",
"proposal-numeric-separator",
"proposal-optional-catch-binding",
"proposal-nullish-coalescing-operator",
"proposal-optional-chaining",
"dynamic-import-node",
],
filename: sucraseOptions.filePath || getFilePath(),
presets,
plugins,
parserOpts: {
plugins: [
"classProperties",
Expand All @@ -155,20 +166,21 @@ function runTypeScript(): {code: string; time: number | null} {
return {code: "Loading TypeScript...", time: null};
}
const {transpileModule, ModuleKind, JsxEmit, ScriptTarget} = TypeScript;
for (const {name} of TRANSFORMS) {
if (["typescript", "imports", "jsx"].includes(name)) {
continue;
}
if (config.selectedTransforms[name]) {
return {code: `Transform "${name}" is not valid in TypeScript.`, time: null};
}
const {sucraseOptions} = config;
const invalidTransforms = sucraseOptions.transforms.filter(
(t) => !["typescript", "imports", "jsx"].includes(t),
);
if (invalidTransforms.length > 0) {
return {code: `Transform "${invalidTransforms[0]}" is not valid in TypeScript.`, time: null};
}
return runAndProfile(
() =>
transpileModule(config.code, {
compilerOptions: {
module: config.selectedTransforms.imports ? ModuleKind.CommonJS : ModuleKind.ESNext,
jsx: config.selectedTransforms.jsx ? JsxEmit.React : JsxEmit.Preserve,
module: sucraseOptions.transforms.includes("imports")
? ModuleKind.CommonJS
: ModuleKind.ESNext,
jsx: sucraseOptions.transforms.includes("jsx") ? JsxEmit.React : JsxEmit.Preserve,
target: ScriptTarget.ES2020,
},
}).outputText,
Expand Down
2 changes: 1 addition & 1 deletion website/src/WorkerClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Message, WorkerConfig, WorkerMessage} from "./WorkerProtocol";
import type {Message, WorkerConfig, WorkerMessage} from "./WorkerProtocol";

const CANCELLED_MESSAGE = "SUCRASE JOB CANCELLED";
const TIMEOUT_MESSAGE = "SUCRASE JOB TIMED OUT";
Expand Down
4 changes: 3 additions & 1 deletion website/src/WorkerProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {Options} from "sucrase";

export type Message =
| {type: "SET_CONFIG"; config: WorkerConfig}
| {type: "RUN_SUCRASE"}
Expand All @@ -18,6 +20,6 @@ export interface WorkerConfig {
compareWithBabel: boolean;
compareWithTypeScript: boolean;
code: string;
selectedTransforms: {[transformName: string]: boolean};
sucraseOptions: Options;
showTokens: boolean;
}
6 changes: 3 additions & 3 deletions website/src/getTokens.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {getFormattedTokens, Transform} from "sucrase";
import {getFormattedTokens, Options} from "sucrase";

export default function getTokens(code: string, transforms: Array<Transform>): string {
export default function getTokens(code: string, options: Options): string {
try {
return getFormattedTokens(code, {transforms});
return getFormattedTokens(code, options);
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
Expand Down
Loading

0 comments on commit dac0d7d

Please sign in to comment.