diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb84acc2d29..d3e37552acc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -382,6 +382,18 @@ jobs:
if: matrix.build_playground
run: yarn workspace playground test
+ - name: Stage dev playground compiler bundle
+ if: ${{ matrix.build_playground && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ run: yarn workspace dev-playground stage-master-bundle
+
+ - name: "Upload artifacts: dev playground compiler bundle"
+ if: ${{ matrix.build_playground && github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: dev-playground-master-bundle
+ path: packages/dev-playground/public/playground-bundles/master
+ if-no-files-found: error
+
- name: Setup Rclone
if: ${{ matrix.build_playground && startsWith(github.ref, 'refs/tags/v') }}
uses: cometkim/rclone-actions/setup-rclone@main
@@ -430,6 +442,62 @@ jobs:
name: api
path: scripts/res/apiDocs/
+ dev-playground:
+ needs:
+ - build-compiler
+ if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}dev-playground/
+ env:
+ VITE_DEFAULT_COMPILER_VERSION: master
+ VITE_COMPILER_VERSIONS: '[{"id":"master","label":"master"}]'
+ GITHUB_PAGES_PATH: dev-playground
+ PLAYGROUND_BUNDLE_ID: master
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Use Node.js
+ uses: actions/setup-node@v6
+ with:
+ cache: yarn
+ node-version-file: .nvmrc
+
+ - name: Install npm packages
+ run: yarn install
+
+ - name: Download dev playground compiler bundle
+ uses: actions/download-artifact@v8
+ with:
+ name: dev-playground-master-bundle
+ path: packages/dev-playground/public/playground-bundles/master
+
+ - name: Configure GitHub Pages
+ id: pages
+ uses: actions/configure-pages@v6
+
+ - name: Build dev playground Pages site
+ env:
+ VITE_BASE: ${{ steps.pages.outputs.base_path }}/dev-playground/
+ run: |
+ yarn workspace dev-playground build
+ yarn workspace dev-playground prepare-pages-site
+
+ - name: Upload GitHub Pages artifact
+ uses: actions/upload-pages-artifact@v5
+ with:
+ path: packages/dev-playground/pages-site
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
+
pkg-pr-new:
needs:
- build-compiler
diff --git a/.gitignore b/.gitignore
index 0b5c90556f6..ba748abd224 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,6 +72,12 @@ playground/*.cmj
playground/*.cmi
playground/.netrc
playground/compiler.*js
+packages/dev-playground/dist/
+packages/dev-playground/pages-site/
+packages/dev-playground/lib/
+packages/dev-playground/src/*.res.mjs
+packages/dev-playground/public/playground-bundles/*
+!packages/dev-playground/public/playground-bundles/.gitignore
rewatch/target/
rewatch/rewatch
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7cf6c436c6..11a67091fb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -45,6 +45,7 @@
#### :house: Internal
- Remove `Primitive_option.toUndefined`; use `valFromOption` for optional ffi args. https://github.com/rescript-lang/rescript/pull/8380
+- Add a developer playground for testing the current compiler bundle locally and deploy the latest `master` build to GitHub Pages. https://github.com/rescript-lang/rescript/pull/8435
- Expand `super_errors` fixture coverage for warnings and errors. https://github.com/rescript-lang/rescript/pull/8429
- Run `super_errors` fixtures in parallel (~2.4× faster locally). https://github.com/rescript-lang/rescript/pull/8430
- Expand `super_errors` fixture coverage for the remaining reachable single-file error variants. https://github.com/rescript-lang/rescript/pull/8432
diff --git a/Makefile b/Makefile
index 78237348502..9b096b7c00e 100644
--- a/Makefile
+++ b/Makefile
@@ -204,6 +204,15 @@ $(PLAYGROUND_CMI_BUILD_STAMP): $(RUNTIME_BUILD_STAMP)
playground-test: playground
yarn workspace playground test
+dev-playground-stage: playground
+ yarn workspace dev-playground stage-local-bundle
+
+dev-playground: dev-playground-stage
+ yarn workspace dev-playground dev
+
+dev-playground-build: dev-playground-stage
+ yarn workspace dev-playground build
+
# Builds the playground, runs some e2e tests and releases the playground to the
# Cloudflare R2 (requires Rclone `rescript:` remote)
playground-release: playground-test
diff --git a/compiler/jsoo/jsoo_playground_main.ml b/compiler/jsoo/jsoo_playground_main.ml
index d21a5bb1332..0a750bdda65 100644
--- a/compiler/jsoo/jsoo_playground_main.ml
+++ b/compiler/jsoo/jsoo_playground_main.ml
@@ -51,8 +51,9 @@
* modules in the playground.
* v5: Removed .ml support.
* v6: Added `config.experimental_features` and `config.jsx_preserve_mode` to the BundleConfig.
+ * v7: Added debug dump output APIs for developer playground tooling.
* *)
-let api_version = "6"
+let api_version = "7"
module Js = Js_of_ocaml.Js
@@ -298,6 +299,8 @@ let rescript_parse ~filename src =
structure
module Printer = struct
+ let to_string printer value = Format.asprintf "%a@." printer value
+
let print_expr typ =
Printtyp.reset_names ();
Printtyp.reset_and_mark_loops typ;
@@ -472,7 +475,8 @@ module Compile = struct
List.iter Iter.iter_structure_item structure.str_items;
Js.array (!acc |> Array.of_list)
- let implementation ~(config : BundleConfig.t) ~lang str =
+ let implementation ?(include_debug_outputs = false) ~(config : BundleConfig.t)
+ ~lang str =
let {
BundleConfig.module_system;
warn_flags;
@@ -525,8 +529,8 @@ module Compile = struct
in
let v = Buffer.contents buffer in
let type_hints = collect_type_hints typed_tree in
- Js.Unsafe.(
- obj
+ let attrs =
+ Js.Unsafe.
[|
("js_code", inject @@ Js.string v);
( "warnings",
@@ -536,7 +540,28 @@ module Compile = struct
|> Js.array |> inject) );
("type_hints", inject @@ type_hints);
("type", inject @@ Js.string "success");
- |])
+ |]
+ in
+ if include_debug_outputs then
+ let export_ident_sets = Set_ident.of_list exports in
+ let parsetree = Printer.to_string Printast.implementation ast in
+ let typedtree =
+ Printer.to_string Printtyped.implementation_with_coercion typed_tree
+ in
+ let lambda = Printer.to_string Printlambda.lambda lam in
+ let lam, _ = Lam_convert.convert export_ident_sets lam in
+ let lam = Lam_print.lambda_to_string lam in
+ let debug_attrs =
+ Js.Unsafe.
+ [|
+ ("parsetree", inject @@ Js.string parsetree);
+ ("typedtree", inject @@ Js.string typedtree);
+ ("lambda", inject @@ Js.string lambda);
+ ("lam", inject @@ Js.string lam);
+ |]
+ in
+ Js.Unsafe.obj (Array.append attrs debug_attrs)
+ else Js.Unsafe.obj attrs
with e -> (
match e with
| Arg.Bad msg -> ErrorRet.make_warning_flag_error ~warn_flags msg
@@ -582,6 +607,11 @@ module Export = struct
inject
@@ Js.wrap_meth_callback (fun _ code ->
Compile.implementation ~config ~lang (Js.to_string code)) );
+ ( "compileWithDebug",
+ inject
+ @@ Js.wrap_meth_callback (fun _ code ->
+ Compile.implementation ~include_debug_outputs:true ~config
+ ~lang (Js.to_string code)) );
("version", inject @@ Js.string Bs_version.version);
|]
in
diff --git a/package.json b/package.json
index 33bc618206b..49112227b6c 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
"typescript": "6.0.3"
},
"workspaces": [
+ "packages/dev-playground",
"packages/playground",
"packages/@rescript/*",
"tests/dependencies/**",
diff --git a/packages/dev-playground/index.html b/packages/dev-playground/index.html
new file mode 100644
index 00000000000..d663a07e584
--- /dev/null
+++ b/packages/dev-playground/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+ ReScript Developer Playground
+
+
+
+
+
+
+
diff --git a/packages/dev-playground/package.json b/packages/dev-playground/package.json
new file mode 100644
index 00000000000..24528385e85
--- /dev/null
+++ b/packages/dev-playground/package.json
@@ -0,0 +1,22 @@
+{
+ "private": true,
+ "name": "dev-playground",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "stage-local-bundle": "node scripts/stage-local-bundle.mjs",
+ "stage-master-bundle": "node scripts/stage-local-bundle.mjs master --clear-other-bundles",
+ "prepare-pages-site": "node scripts/prepare-pages-site.mjs",
+ "res:build": "rescript",
+ "res:watch": "rescript -w",
+ "dev": "vite --host 127.0.0.1",
+ "build": "rescript && vite build",
+ "preview": "vite preview --host 127.0.0.1"
+ },
+ "dependencies": {
+ "@rescript/runtime": "12.3.0",
+ "rescript": "12.3.0",
+ "vite": "^7.3.2",
+ "xote": "6.1.1"
+ }
+}
diff --git a/packages/dev-playground/public/playground-bundles/.gitignore b/packages/dev-playground/public/playground-bundles/.gitignore
new file mode 100644
index 00000000000..d6b7ef32c84
--- /dev/null
+++ b/packages/dev-playground/public/playground-bundles/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
diff --git a/packages/dev-playground/rescript.json b/packages/dev-playground/rescript.json
new file mode 100644
index 00000000000..d0c01ad063a
--- /dev/null
+++ b/packages/dev-playground/rescript.json
@@ -0,0 +1,26 @@
+{
+ "name": "dev-playground",
+ "sources": [
+ {
+ "dir": "src",
+ "subdirs": true
+ }
+ ],
+ "package-specs": {
+ "module": "esmodule",
+ "in-source": true
+ },
+ "suffix": ".res.mjs",
+ "dependencies": ["xote"],
+ "jsx": {
+ "version": 4,
+ "module": "XoteJSX"
+ },
+ "experimental-features": {
+ "LetUnwrap": true
+ },
+ "compiler-flags": ["-open Xote", "-open Bindings"],
+ "warnings": {
+ "error": "+8"
+ }
+}
diff --git a/packages/dev-playground/scripts/prepare-pages-site.mjs b/packages/dev-playground/scripts/prepare-pages-site.mjs
new file mode 100644
index 00000000000..94e00149c5b
--- /dev/null
+++ b/packages/dev-playground/scripts/prepare-pages-site.mjs
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
+const devPlaygroundDir = path.join(import.meta.dirname, "..");
+const distDir = path.join(devPlaygroundDir, "dist");
+const siteDir = path.join(devPlaygroundDir, "pages-site");
+const sitePath = process.env.GITHUB_PAGES_PATH ?? "dev-playground";
+const bundleId = process.env.PLAYGROUND_BUNDLE_ID ?? "master";
+const commitSha = process.env.GITHUB_SHA ?? "unknown";
+const targetDir = path.join(siteDir, sitePath);
+
+async function assertExists(filePath, message) {
+ try {
+ await fs.stat(filePath);
+ } catch {
+ throw new Error(`${message}: ${filePath}`);
+ }
+}
+
+await assertExists(
+ distDir,
+ "Missing dev playground build. Run `yarn workspace dev-playground build` first",
+);
+
+await fs.rm(siteDir, { recursive: true, force: true });
+await fs.mkdir(targetDir, { recursive: true });
+await fs.cp(distDir, targetDir, { recursive: true });
+
+const catalog = {
+ generatedAt: new Date().toISOString(),
+ defaultBundle: bundleId,
+ bundles: [
+ {
+ id: bundleId,
+ label: bundleId,
+ channel: bundleId,
+ commit: commitSha,
+ root: `playground-bundles/${bundleId}`,
+ },
+ ],
+};
+
+await fs.writeFile(
+ path.join(targetDir, "catalog.json"),
+ `${JSON.stringify(catalog, null, 2)}\n`,
+);
+
+await fs.writeFile(
+ path.join(siteDir, "index.html"),
+ `
+
+
+
+
+ ReScript Developer Playground
+
+
+ Open ReScript Developer Playground
+
+
+`,
+);
+
+await assertExists(
+ path.join(targetDir, "index.html"),
+ "Missing deployed dev playground index",
+);
+await assertExists(
+ path.join(targetDir, "playground-bundles", bundleId, "compiler.js"),
+ "Missing deployed playground compiler bundle",
+);
+await assertExists(
+ path.join(
+ targetDir,
+ "playground-bundles",
+ bundleId,
+ "compiler-builtins",
+ "cmij.js",
+ ),
+ "Missing deployed compiler-builtins cmij bundle",
+);
+
+console.log(`Prepared GitHub Pages site at ${siteDir}`);
diff --git a/packages/dev-playground/scripts/stage-local-bundle.mjs b/packages/dev-playground/scripts/stage-local-bundle.mjs
new file mode 100644
index 00000000000..dfaddd0ba54
--- /dev/null
+++ b/packages/dev-playground/scripts/stage-local-bundle.mjs
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+
+import * as fs from "node:fs/promises";
+import * as path from "node:path";
+
+const devPlaygroundDir = path.join(import.meta.dirname, "..");
+const repoRoot = path.join(devPlaygroundDir, "..", "..");
+const playgroundDir = path.join(repoRoot, "packages", "playground");
+const sourceCompiler = path.join(playgroundDir, "compiler.js");
+const sourcePackages = path.join(playgroundDir, "packages");
+const args = process.argv.slice(2);
+const bundleId = args.find(arg => !arg.startsWith("--")) ?? "local";
+const clearOtherBundles = args.includes("--clear-other-bundles");
+const bundlesRoot = path.join(devPlaygroundDir, "public", "playground-bundles");
+const targetRoot = path.join(bundlesRoot, bundleId);
+
+async function assertExists(filePath, message) {
+ try {
+ await fs.stat(filePath);
+ } catch {
+ throw new Error(`${message}: ${filePath}`);
+ }
+}
+
+await assertExists(
+ sourceCompiler,
+ "Missing playground compiler bundle. Run `make playground` first",
+);
+await assertExists(
+ sourcePackages,
+ "Missing playground cmij packages. Run `make playground` first",
+);
+
+if (clearOtherBundles) {
+ for (const entry of await fs.readdir(bundlesRoot)) {
+ if (entry !== ".gitignore") {
+ await fs.rm(path.join(bundlesRoot, entry), {
+ recursive: true,
+ force: true,
+ });
+ }
+ }
+}
+
+await fs.rm(targetRoot, { recursive: true, force: true });
+await fs.mkdir(targetRoot, { recursive: true });
+await fs.copyFile(sourceCompiler, path.join(targetRoot, "compiler.js"));
+await fs.cp(sourcePackages, targetRoot, { recursive: true });
+
+console.log(`Staged ${bundleId} playground bundle at ${targetRoot}`);
diff --git a/packages/dev-playground/src/Bindings.res b/packages/dev-playground/src/Bindings.res
new file mode 100644
index 00000000000..694aad80805
--- /dev/null
+++ b/packages/dev-playground/src/Bindings.res
@@ -0,0 +1,372 @@
+type compilerApi
+type compilerInstance
+type rescriptCompiler
+type compilerConfig
+type compileResult
+type diagnostic
+
+module Env = {
+ @val
+ external viteDefaultCompilerVersion: option =
+ "import.meta.env.VITE_DEFAULT_COMPILER_VERSION"
+ @val external viteCompilerVersions: option = "import.meta.env.VITE_COMPILER_VERSIONS"
+ @val external viteBaseUrl: option = "import.meta.env.BASE_URL"
+}
+
+module DynamicProperty = {
+ @get_index external get: ('value, string) => option = ""
+}
+
+module Api = {
+ @val external global: option = "globalThis.rescript_compiler"
+ @send external makeCompiler: compilerApi => compilerInstance = "make"
+ @get external apiVersion: compilerApi => option = "api_version"
+}
+
+module Instance = {
+ @send external setModuleSystem: (compilerInstance, string) => unit = "setModuleSystem"
+ @send external setWarnFlags: (compilerInstance, string) => unit = "setWarnFlags"
+ @send external setFilename: (compilerInstance, string) => unit = "setFilename"
+ @send external setJsxPreserveMode: (compilerInstance, bool) => unit = "setJsxPreserveMode"
+ @send
+ external setExperimentalFeatures: (compilerInstance, array) => unit =
+ "setExperimentalFeatures"
+ @get external rescript: compilerInstance => rescriptCompiler = "rescript"
+ @send external getConfig: compilerInstance => compilerConfig = "getConfig"
+ @get external version: compilerInstance => option = "version"
+}
+
+module Rescript = {
+ @get external version: rescriptCompiler => option = "version"
+ @send external compile: (rescriptCompiler, string) => compileResult = "compile"
+ @send external compileWithDebug: (rescriptCompiler, string) => compileResult = "compileWithDebug"
+ @send external format: (rescriptCompiler, string) => compileResult = "format"
+}
+
+module Config = {
+ @get external moduleSystem: compilerConfig => option = "module_system"
+ @get external warnFlags: compilerConfig => option = "warn_flags"
+ @get external jsxPreserveMode: compilerConfig => option = "jsx_preserve_mode"
+ @get
+ external experimentalFeatures: compilerConfig => option> = "experimental_features"
+}
+
+module Diagnostic = {
+ @get external row: diagnostic => option = "row"
+ @get external column: diagnostic => option = "column"
+ @get external warnNumber: diagnostic => option = "warnNumber"
+ @get external isError: diagnostic => option = "isError"
+ @get external shortMsg: diagnostic => option = "shortMsg"
+ @get external fullMsg: diagnostic => option = "fullMsg"
+}
+
+module CompileResult = {
+ @get external type_: compileResult => option = "type"
+ @get external code: compileResult => option = "code"
+ @get external jsCode: compileResult => option = "js_code"
+ @get external parsetree: compileResult => option = "parsetree"
+ @get external typedtree: compileResult => option = "typedtree"
+ @get external lambda: compileResult => option = "lambda"
+ @get external lam: compileResult => option = "lam"
+ @get external errors: compileResult => option> = "errors"
+ @get external warnings: compileResult => option> = "warnings"
+ @get external msg: compileResult => option = "msg"
+ @get external shortMsg: compileResult => option = "shortMsg"
+ @get external fullMsg: compileResult => option = "fullMsg"
+}
+
+module Window = {
+ @val external setTimeout: (unit => unit, int) => int = "setTimeout"
+ @val external clearTimeout: int => unit = "clearTimeout"
+ @val external requestAnimationFrame: (unit => unit) => unit = "window.requestAnimationFrame"
+ @val external isSecureContext: bool = "window.isSecureContext"
+}
+
+module Url = {
+ type t
+
+ @new external make: (string, string) => t = "URL"
+ @get external href: t => string = "href"
+ @get external pathname: t => string = "pathname"
+}
+
+module Event = {
+ @get external target: Dom.event => {..} = "target"
+ @get external key: Dom.event => string = "key"
+ @send external preventDefault: Dom.event => unit = "preventDefault"
+
+ let value = (event: Dom.event): string => (event->target)["value"]
+
+ let checked = (event: Dom.event): bool => (event->target)["checked"]
+
+ let selectionStart = (event: Dom.event): int => (event->target)["selectionStart"]
+
+ let scrollTop = (event: Dom.event): int => {
+ let scrollTop: float = (event->target)["scrollTop"]
+ scrollTop->Math.round->Float.toInt
+ }
+
+ let scrollLeft = (event: Dom.event): int => {
+ let scrollLeft: float = (event->target)["scrollLeft"]
+ scrollLeft->Math.round->Float.toInt
+ }
+}
+
+module EventTarget = {
+ let value = (target: {..}): string => target["value"]
+ let setValue = (target: {..}, value: string) => target["value"] = value
+ let selectionStart = (target: {..}): int => target["selectionStart"]
+ let selectionEnd = (target: {..}): int => target["selectionEnd"]
+ let setSelectionRange = (target: {..}, start, end_) => {
+ let setSelectionRange: (int, int) => unit = target["setSelectionRange"]
+ setSelectionRange(start, end_)
+ }
+}
+
+module CssStyle = {
+ type t
+
+ @set external setPosition: (t, string) => unit = "position"
+ @set external setTop: (t, string) => unit = "top"
+ @set external setLeft: (t, string) => unit = "left"
+}
+
+module Element = {
+ @send external setAttribute: (Dom.element, string, string) => unit = "setAttribute"
+ @send
+ external addEventListener: (Dom.element, string, Dom.event => unit) => unit = "addEventListener"
+ @send
+ external removeEventListener: (Dom.element, string, Dom.event => unit) => unit =
+ "removeEventListener"
+ @send external appendChild: (Dom.element, Dom.element) => unit = "appendChild"
+ @send external removeChild: (Dom.element, Dom.element) => unit = "removeChild"
+ @get external style: Dom.element => CssStyle.t = "style"
+ @get @return(nullable)
+ external getScrollHandler: Dom.element => option unit> =
+ "__devPlaygroundScrollHandler"
+ @set
+ external setScrollHandler: (Dom.element, Dom.event => unit) => unit =
+ "__devPlaygroundScrollHandler"
+}
+
+module ScriptElement = {
+ @set external setSrc: (Dom.element, string) => unit = "src"
+ @set external setAsync: (Dom.element, bool) => unit = "async"
+ @set external setOnLoad: (Dom.element, unknown => unit) => unit = "onload"
+ @set external setOnError: (Dom.element, unknown => unit) => unit = "onerror"
+}
+
+module TextAreaElement = {
+ @set external setValue: (Dom.element, string) => unit = "value"
+ @send external select: Dom.element => unit = "select"
+}
+
+module Document = {
+ @val external current: {..} = "document"
+ @get external head: {..} => Dom.element = "head"
+ @get external body: {..} => Dom.element = "body"
+ @send external createScriptElement: ({..}, @as("script") _) => Dom.element = "createElement"
+ @send external createTextAreaElement: ({..}, @as("textarea") _) => Dom.element = "createElement"
+ @send @return(nullable)
+ external getElementById: ({..}, string) => option = "getElementById"
+ @send external execCommand: ({..}, string) => bool = "execCommand"
+}
+
+module UrlSearchParams = {
+ type t
+
+ @new external make: string => t = "URLSearchParams"
+ @send @return(nullable) external get: (t, string) => option = "get"
+ @send external set: (t, string, string) => unit = "set"
+ @send external delete: (t, string) => unit = "delete"
+ @send external toString: t => string = "toString"
+}
+
+module Location = {
+ @val external search: string = "window.location.search"
+ @val external pathname: string = "window.location.pathname"
+ @val external hash: string = "window.location.hash"
+ @val external href: string = "window.location.href"
+ @val external origin: string = "window.location.origin"
+}
+
+module History = {
+ @val @scope(("window", "history"))
+ external replaceState: (@as(json`null`) _, @as("") _, string) => unit = "replaceState"
+}
+
+module Performance = {
+ @val @scope("performance") external now: unit => float = "now"
+}
+
+module Base64 = {
+ @val external encode: string => string = "btoa"
+ @val external decode: string => string = "atob"
+}
+
+module WebTextEncoder = {
+ type t
+
+ @new external make: unit => t = "TextEncoder"
+ @send external encode: (t, string) => Uint8Array.t = "encode"
+}
+
+module WebTextDecoder = {
+ type t
+
+ @new external make: unit => t = "TextDecoder"
+ @send external decode: (t, Uint8Array.t) => string = "decode"
+}
+
+module WebDecompressionStream = {
+ type t
+
+ @val external supported: option = "globalThis.DecompressionStream"
+ @new external make: string => t = "DecompressionStream"
+}
+
+module ReadableStream = {
+ type t
+
+ @send external pipeThrough: (t, WebDecompressionStream.t) => t = "pipeThrough"
+}
+
+module WebBlob = {
+ type t
+
+ @new external make: array => t = "Blob"
+ @send external stream: t => ReadableStream.t = "stream"
+}
+
+module WebResponse = {
+ type t
+
+ @new external make: ReadableStream.t => t = "Response"
+ @send external arrayBuffer: t => promise = "arrayBuffer"
+}
+
+module SharedCode = {
+ let bytesToBinary = bytes => {
+ let chunkSize = 0x8000
+ let length = bytes->TypedArray.length
+ let chunks: array = []
+
+ let rec collect = start =>
+ if start < length {
+ let end_ = Math.Int.min(start + chunkSize, length)
+ let chunk = bytes->TypedArray.subarray(~start, ~end=end_)
+ let chars = Array.fromInitializer(~length=end_ - start, index =>
+ chunk->TypedArray.get(index)->Option.getOr(0)
+ )
+ chunks->Array.push(chars->String.fromCharCodeMany)
+ collect(end_)
+ }
+
+ collect(0)
+ chunks->Array.join("")
+ }
+
+ let base64UrlToBytes = value => {
+ let base64 = value->String.replaceAll("-", "+")->String.replaceAll("_", "/")
+ let remainder = mod(base64->String.length, 4)
+ let padded = switch remainder {
+ | 0 => base64
+ | remainder => base64->String.padEnd(base64->String.length + 4 - remainder, "=")
+ }
+ let binary = padded->Base64.decode
+ let length = binary->String.length
+ let bytes = Uint8Array.fromLength(length)
+
+ for index in 0 to length - 1 {
+ bytes->TypedArray.set(index, binary->String.charCodeAtUnsafe(index))
+ }
+
+ bytes
+ }
+
+ let encode = async source => {
+ let bytes = WebTextEncoder.make()->WebTextEncoder.encode(source)
+ "b:" ++
+ bytes
+ ->bytesToBinary
+ ->Base64.encode
+ ->String.replaceAllRegExp(/\+/g, "-")
+ ->String.replaceAllRegExp(/\//g, "_")
+ ->String.replaceAllRegExp(/=+$/g, "")
+ }
+
+ let decode = async encoded =>
+ if encoded->String.startsWith("z:") {
+ switch WebDecompressionStream.supported {
+ | None =>
+ JsError.throwWithMessage(
+ "Compressed shared links require browser DecompressionStream support",
+ )
+ | Some(_) =>
+ let compressedBytes = encoded->String.slice(~start=2)->base64UrlToBytes
+ let stream =
+ WebBlob.make([compressedBytes])
+ ->WebBlob.stream
+ ->ReadableStream.pipeThrough(WebDecompressionStream.make("gzip"))
+ let buffer = await WebResponse.make(stream)->WebResponse.arrayBuffer
+ WebTextDecoder.make()->WebTextDecoder.decode(Uint8Array.fromBuffer(buffer))
+ }
+ } else if encoded->String.startsWith("b:") {
+ WebTextDecoder.make()->WebTextDecoder.decode(
+ encoded->String.slice(~start=2)->base64UrlToBytes,
+ )
+ } else {
+ encoded
+ }
+}
+
+module NavigatorClipboard = {
+ type t
+
+ @val external current: option = "navigator.clipboard"
+ @get @return(nullable) external writeTextMethod: t => option = "writeText"
+ @send external writeText: (t, string) => promise = "writeText"
+
+ let canWriteText = clipboard =>
+ switch clipboard->writeTextMethod {
+ | Some(writeText) => writeText->Type.typeof === #function
+ | None => false
+ }
+}
+
+module Clipboard = {
+ let writeWithFallback = value => {
+ let document = Document.current
+ let textarea = document->Document.createTextAreaElement
+ textarea->TextAreaElement.setValue(value)
+ textarea->Element.setAttribute("readonly", "")
+
+ let style = textarea->Element.style
+ style->CssStyle.setPosition("fixed")
+ style->CssStyle.setTop("-9999px")
+ style->CssStyle.setLeft("-9999px")
+
+ let body = document->Document.body
+ body->Element.appendChild(textarea)
+ textarea->TextAreaElement.select
+
+ let copied = switch document->Document.execCommand("copy") {
+ | copied => Ok(copied)
+ | exception _ => Error()
+ }
+
+ body->Element.removeChild(textarea)
+
+ switch copied {
+ | Ok(true) => ()
+ | Ok(false) | Error() => JsError.throwWithMessage("Copy command failed")
+ }
+ }
+
+ let writeText = async value =>
+ switch NavigatorClipboard.current {
+ | Some(clipboard) if Window.isSecureContext && clipboard->NavigatorClipboard.canWriteText =>
+ await clipboard->NavigatorClipboard.writeText(value)
+ | _ => writeWithFallback(value)
+ }
+}
diff --git a/packages/dev-playground/src/CompilerApi.res b/packages/dev-playground/src/CompilerApi.res
new file mode 100644
index 00000000000..12fb07e3602
--- /dev/null
+++ b/packages/dev-playground/src/CompilerApi.res
@@ -0,0 +1,474 @@
+module Version = {
+ type t = {
+ id: string,
+ label: string,
+ }
+
+ let jsonStringField = (item, name) =>
+ switch item->Dict.get(name) {
+ | Some(JSON.String(value)) => Some(value)
+ | _ => None
+ }
+
+ let fromJson = json =>
+ switch json {
+ | JSON.Object(item) =>
+ let? Some(id) = item->jsonStringField("id")
+ let? Some(label) = item->jsonStringField("label")
+ Some({id, label})
+ | _ => None
+ }
+}
+
+type info = {
+ bundleId: string,
+ version: string,
+ apiVersion: string,
+ moduleSystem: PlaygroundConfig.moduleSystem,
+ warnFlags: string,
+ jsxPreserveMode: bool,
+ experimentalFeatures: array,
+ libraries: array,
+}
+
+type success = {
+ jsCode: string,
+ parsetree: string,
+ typedtree: string,
+ lambda: string,
+ lam: string,
+ warnings: array,
+ time: float,
+}
+
+type failure = {
+ errors: array,
+ warnings: array,
+ message: string,
+ time: float,
+}
+
+type compileResult = result
+type formatResult = result
+
+type normalizedConfig = {
+ moduleSystem: PlaygroundConfig.moduleSystem,
+ warnFlags: string,
+ jsxPreserveMode: bool,
+ experimentalFeatures: array,
+}
+
+let defaultWarnFlags = "+a-4-9-20-40-41-42-50-61-102-109"
+
+let defaultCompilerVersion = Env.viteDefaultCompilerVersion->Option.getOr("local")
+
+let defaultConfig: PlaygroundConfig.t = {
+ compilerVersion: defaultCompilerVersion,
+ moduleSystem: Esmodule,
+ warnFlags: defaultWarnFlags,
+ jsxPreserveMode: false,
+ experimentalFeatures: [],
+}
+
+let pathFromBase = relativePath => {
+ let baseUrl = switch Env.viteBaseUrl {
+ | Some("") | None => "/"
+ | Some(baseUrl) => baseUrl
+ }
+
+ let pathname = Url.make(relativePath, Url.make(baseUrl, Location.origin)->Url.href)->Url.pathname
+
+ if pathname->String.endsWith("/") {
+ pathname->String.slice(~start=0, ~end=pathname->String.length - 1)
+ } else {
+ pathname
+ }
+}
+
+let parseCompilerVersions = defaultVersion => {
+ let fallback = [{Version.id: defaultVersion, label: defaultVersion}]
+ switch Env.viteCompilerVersions {
+ | None | Some("") => fallback
+ | Some(versionJson) =>
+ switch JSON.parseOrThrow(versionJson) {
+ | JSON.Array(items) =>
+ let versions = items->Array.filterMap(Version.fromJson)
+ versions->Array.length === items->Array.length ? versions : fallback
+ | _ => fallback
+ | exception _ => fallback
+ }
+ }
+}
+
+let availableCompilerVersions = parseCompilerVersions(defaultConfig.compilerVersion)
+let compilerRoot = pathFromBase("playground-bundles")
+let loadedScripts: Map.t> = Map.make()
+let compilerApis: Map.t = Map.make()
+let compilers: Map.t = Map.make()
+let loadedLibrariesByVersion: Map.t> = Map.make()
+let activeLibraryVersion = ref(None)
+
+let hasFunction = (value, name) =>
+ switch value->DynamicProperty.get(name) {
+ | Some(value) => value->Type.typeof === #function
+ | None => false
+ }
+
+let versionOrDefault = version => version === "" ? defaultConfig.compilerVersion : version
+
+let createScriptLoadPromise = src =>
+ Promise.make((resolve, reject) => {
+ let document = Document.current
+ let script = document->Document.createScriptElement
+ script->ScriptElement.setSrc(src)
+ script->ScriptElement.setAsync(true)
+ script->ScriptElement.setOnLoad(_ => resolve())
+ script->ScriptElement.setOnError(_ => reject(JsError.make(`Could not load ${src}`)))
+ document->Document.head->Element.appendChild(script)
+ })
+
+let loadScript = (src, ~cache=true) =>
+ if cache {
+ switch loadedScripts->Map.get(src) {
+ | Some(promise) => promise
+ | None =>
+ let promise = createScriptLoadPromise(src)
+ loadedScripts->Map.set(src, promise)
+ promise
+ }
+ } else {
+ createScriptLoadPromise(src)
+ }
+
+let versionRoot = version => `${compilerRoot}/${versionOrDefault(version)}`
+
+let applyConfig = (
+ instance,
+ ~moduleSystem: PlaygroundConfig.moduleSystem,
+ ~warnFlags,
+ ~jsxPreserveMode,
+ ~experimentalFeatures: array,
+) => {
+ if hasFunction(instance, "setModuleSystem") {
+ instance->Instance.setModuleSystem((moduleSystem :> string))
+ }
+ if hasFunction(instance, "setWarnFlags") {
+ instance->Instance.setWarnFlags(warnFlags === "" ? defaultConfig.warnFlags : warnFlags)
+ }
+ if hasFunction(instance, "setFilename") {
+ instance->Instance.setFilename("Playground.res")
+ }
+ if hasFunction(instance, "setJsxPreserveMode") {
+ instance->Instance.setJsxPreserveMode(jsxPreserveMode)
+ }
+ if hasFunction(instance, "setExperimentalFeatures") {
+ instance->Instance.setExperimentalFeatures(
+ experimentalFeatures->Array.map(feature => (feature :> string)),
+ )
+ }
+}
+
+let moduleSystemFromConfig = configValue =>
+ switch configValue->Config.moduleSystem {
+ | Some(moduleSystem) =>
+ switch moduleSystem->PlaygroundConfig.parseModuleSystem {
+ | Some(moduleSystem) => moduleSystem
+ | None => Esmodule
+ }
+ | None => Esmodule
+ }
+
+let experimentalFeaturesFromConfig = configValue =>
+ switch configValue->Config.experimentalFeatures {
+ | Some(experimentalFeatures) =>
+ experimentalFeatures->Array.filterMap(PlaygroundConfig.parseExperimentalFeature)
+ | None => []
+ }
+
+let normalizeConfig = (configValue: option): normalizedConfig =>
+ switch configValue {
+ | None => {
+ moduleSystem: Esmodule,
+ warnFlags: defaultConfig.warnFlags,
+ jsxPreserveMode: false,
+ experimentalFeatures: [],
+ }
+ | Some(configValue) => {
+ moduleSystem: configValue->moduleSystemFromConfig,
+ warnFlags: switch configValue->Config.warnFlags {
+ | Some(warnFlags) => warnFlags
+ | None => defaultConfig.warnFlags
+ },
+ jsxPreserveMode: switch configValue->Config.jsxPreserveMode {
+ | Some(jsxPreserveMode) => jsxPreserveMode
+ | None => false
+ },
+ experimentalFeatures: configValue->experimentalFeaturesFromConfig,
+ }
+ }
+
+let getConfigIfAvailable = (instance: compilerInstance): option =>
+ if hasFunction(instance, "getConfig") {
+ Some(instance->Instance.getConfig)
+ } else {
+ None
+ }
+
+let diagnosticMessage = (item, fallback) =>
+ item->Diagnostic.shortMsg->Option.orElse(item->Diagnostic.fullMsg)->Option.getOr(fallback)
+
+let formatLocation = item => {
+ let row = switch item->Diagnostic.row {
+ | Some(row) => row
+ | None => 0
+ }
+
+ let column = switch item->Diagnostic.column {
+ | Some(column) => column
+ | None => 0
+ }
+
+ row > 0 ? `Line ${row->Int.toString}, ${column->Int.toString}` : "Compiler"
+}
+
+let warningToText = item => {
+ let prefix = switch item->Diagnostic.isError {
+ | Some(true) => "error"
+ | Some(false) | None => "warning"
+ }
+
+ let warnNumber = switch item->Diagnostic.warnNumber {
+ | Some(warnNumber) => ` ${warnNumber->Int.toString}`
+ | None => ""
+ }
+
+ let message = diagnosticMessage(item, "Unknown warning")
+ `${formatLocation(item)}: ${prefix}${warnNumber}: ${message}`
+}
+
+let errorToText = item => {
+ let message = diagnosticMessage(item, "Unknown compiler error")
+ `${formatLocation(item)}: ${message}`
+}
+
+let failureFromCompileOutput = (compileOutput, elapsedMs): failure => {
+ let errors = switch compileOutput->CompileResult.errors {
+ | Some(errors) => errors->Array.map(errorToText)
+ | None => []
+ }
+
+ let warnings = switch compileOutput->CompileResult.warnings {
+ | Some(warnings) => warnings->Array.map(warningToText)
+ | None => []
+ }
+
+ let message =
+ compileOutput
+ ->CompileResult.msg
+ ->Option.orElse(compileOutput->CompileResult.shortMsg)
+ ->Option.orElse(compileOutput->CompileResult.fullMsg)
+ ->Option.orElse(errors->Array.get(0))
+ ->Option.getOr("Compilation failed")
+
+ {errors, warnings, message, time: elapsedMs}
+}
+
+let normalize = (compileOutput, elapsedMs): compileResult => {
+ switch (
+ compileOutput->CompileResult.parsetree,
+ compileOutput->CompileResult.typedtree,
+ compileOutput->CompileResult.lambda,
+ compileOutput->CompileResult.lam,
+ ) {
+ | (Some(parsetree), Some(typedtree), Some(lambda), Some(lam)) =>
+ let warnings = switch compileOutput->CompileResult.warnings {
+ | Some(warnings) => warnings->Array.map(warningToText)
+ | None => []
+ }
+
+ let jsCode = switch compileOutput->CompileResult.jsCode {
+ | Some(jsCode) => jsCode
+ | None => ""
+ }
+
+ Ok({jsCode, parsetree, typedtree, lambda, lam, warnings, time: elapsedMs})
+
+ | _ => Error(failureFromCompileOutput(compileOutput, elapsedMs))
+ }
+}
+
+let loadRuntimeLibraries = async version => {
+ let selectedVersion = versionOrDefault(version)
+ switch activeLibraryVersion.contents {
+ | Some(activeVersion) if activeVersion === selectedVersion => ()
+ | _ =>
+ let root = versionRoot(selectedVersion)
+ let _ = await loadScript(`${root}/compiler-builtins/cmij.js`, ~cache=false)
+
+ let libraries = try {
+ let _ = await loadScript(`${root}/@rescript/react/cmij.js`, ~cache=false)
+ ["compiler-builtins", "@rescript/react"]
+ } catch {
+ | _ => ["compiler-builtins"]
+ }
+
+ loadedLibrariesByVersion->Map.set(selectedVersion, libraries)
+ activeLibraryVersion := Some(selectedVersion)
+ }
+}
+
+let getMapValueOrThrow = (map: Map.t, key, message): 'value =>
+ switch map->Map.get(key) {
+ | Some(value) => value
+ | None => JsError.throwWithMessage(message)
+ }
+
+let ensureCompilerApi = async version => {
+ let selectedVersion = versionOrDefault(version)
+ if compilerApis->Map.has(selectedVersion) {
+ let _ = await loadRuntimeLibraries(selectedVersion)
+ compilerApis->getMapValueOrThrow(selectedVersion, "Compiler API was not cached")
+ } else {
+ let root = versionRoot(selectedVersion)
+ let _ = await loadScript(`${root}/compiler.js`)
+ let _ = await loadRuntimeLibraries(selectedVersion)
+
+ let api = switch Api.global {
+ | Some(api) if hasFunction(api, "make") => api
+ | _ => JsError.throwWithMessage("rescript_compiler global was not registered by compiler.js")
+ }
+
+ compilerApis->Map.set(selectedVersion, api)
+ api
+ }
+}
+
+let ensureCompiler = async version => {
+ let selectedVersion = versionOrDefault(version)
+ let api = await ensureCompilerApi(selectedVersion)
+
+ if compilers->Map.has(selectedVersion) {
+ compilers->getMapValueOrThrow(selectedVersion, "Compiler instance was not cached")
+ } else {
+ let instance = api->Api.makeCompiler
+ applyConfig(
+ instance,
+ ~moduleSystem=Esmodule,
+ ~warnFlags=defaultConfig.warnFlags,
+ ~jsxPreserveMode=false,
+ ~experimentalFeatures=[],
+ )
+
+ compilers->Map.set(selectedVersion, instance)
+ instance
+ }
+}
+
+let instanceVersion = instance =>
+ switch instance->Instance.version {
+ | Some(version) => version
+ | None =>
+ switch instance->Instance.rescript->Rescript.version {
+ | Some(version) => version
+ | None => "unknown"
+ }
+ }
+
+let apiVersion = api =>
+ switch api {
+ | Some(api) =>
+ switch api->Api.apiVersion {
+ | Some(apiVersion) => apiVersion
+ | None => "unknown"
+ }
+ | None => "unknown"
+ }
+
+let resultIsSuccess = compileOutput =>
+ switch compileOutput->CompileResult.type_ {
+ | Some("success") => true
+ | _ => false
+ }
+
+let init = async version => {
+ let selectedVersion = versionOrDefault(version)
+ let instance = await ensureCompiler(selectedVersion)
+ let config = normalizeConfig(instance->getConfigIfAvailable)
+
+ let libraries = switch loadedLibrariesByVersion->Map.get(selectedVersion) {
+ | Some(libraries) => libraries
+ | None => ["compiler-builtins"]
+ }
+
+ {
+ bundleId: selectedVersion,
+ version: instance->instanceVersion,
+ apiVersion: compilerApis->Map.get(selectedVersion)->apiVersion,
+ moduleSystem: config.moduleSystem,
+ warnFlags: config.warnFlags,
+ jsxPreserveMode: config.jsxPreserveMode,
+ experimentalFeatures: config.experimentalFeatures,
+ libraries,
+ }
+}
+
+let compile = async (source, config: PlaygroundConfig.t) => {
+ let selectedVersion = versionOrDefault(config.compilerVersion)
+ let instance = await ensureCompiler(selectedVersion)
+
+ applyConfig(
+ instance,
+ ~moduleSystem=config.moduleSystem,
+ ~warnFlags=config.warnFlags,
+ ~jsxPreserveMode=config.jsxPreserveMode,
+ ~experimentalFeatures=config.experimentalFeatures,
+ )
+
+ let start = Performance.now()
+ let rescript = instance->Instance.rescript
+
+ let compileOutput = if hasFunction(rescript, "compileWithDebug") {
+ rescript->Rescript.compileWithDebug(source)
+ } else {
+ rescript->Rescript.compile(source)
+ }
+ let elapsedMs = Performance.now() - start
+
+ normalize(compileOutput, elapsedMs)
+}
+
+let format = async (source, config: PlaygroundConfig.t) => {
+ let selectedVersion = versionOrDefault(config.compilerVersion)
+ let instance = await ensureCompiler(selectedVersion)
+ applyConfig(
+ instance,
+ ~moduleSystem=config.moduleSystem,
+ ~warnFlags=config.warnFlags,
+ ~jsxPreserveMode=config.jsxPreserveMode,
+ ~experimentalFeatures=config.experimentalFeatures,
+ )
+
+ let start = Performance.now()
+ let rescript = instance->Instance.rescript
+ let formatOutput = if hasFunction(rescript, "format") {
+ rescript->Rescript.format(source)
+ } else {
+ JsError.throwWithMessage("This compiler bundle does not expose formatting")
+ }
+ let elapsedMs = Performance.now() - start
+
+ if formatOutput->resultIsSuccess {
+ switch formatOutput->CompileResult.code {
+ | Some(code) => Ok(code)
+ | None =>
+ Error({
+ errors: [],
+ warnings: [],
+ message: "Formatting did not return code",
+ time: elapsedMs,
+ })
+ }
+ } else {
+ Error(failureFromCompileOutput(formatOutput, elapsedMs))
+ }
+}
diff --git a/packages/dev-playground/src/CompilerApi.resi b/packages/dev-playground/src/CompilerApi.resi
new file mode 100644
index 00000000000..f0b075bade9
--- /dev/null
+++ b/packages/dev-playground/src/CompilerApi.resi
@@ -0,0 +1,47 @@
+module Version: {
+ type t = {
+ id: string,
+ label: string,
+ }
+}
+
+type info = {
+ bundleId: string,
+ version: string,
+ apiVersion: string,
+ moduleSystem: PlaygroundConfig.moduleSystem,
+ warnFlags: string,
+ jsxPreserveMode: bool,
+ experimentalFeatures: array,
+ libraries: array,
+}
+
+type success = {
+ jsCode: string,
+ parsetree: string,
+ typedtree: string,
+ lambda: string,
+ lam: string,
+ warnings: array,
+ time: float,
+}
+
+type failure = {
+ errors: array,
+ warnings: array,
+ message: string,
+ time: float,
+}
+
+type compileResult = result
+type formatResult = result
+
+let defaultConfig: PlaygroundConfig.t
+
+let availableCompilerVersions: array
+
+let init: string => promise
+
+let compile: (string, PlaygroundConfig.t) => promise
+
+let format: (string, PlaygroundConfig.t) => promise
diff --git a/packages/dev-playground/src/Main.res b/packages/dev-playground/src/Main.res
new file mode 100644
index 00000000000..7bd1e608ac1
--- /dev/null
+++ b/packages/dev-playground/src/Main.res
@@ -0,0 +1,721 @@
+open PlaygroundConfig
+
+type tab =
+ | Parsetree
+ | Typedtree
+ | Lambda
+ | Lam
+ | JavaScript
+ | Settings
+
+type compilerStatus =
+ | Loading
+ | Ready
+ | Compiling
+ | Failed(string)
+
+type sourcePosition = {
+ line: int,
+ col: int,
+}
+
+let tabs: array = [Parsetree, Typedtree, Lambda, Lam, JavaScript, Settings]
+let moduleSystems: array = [Esmodule, Commonjs]
+
+let defaultSource = `type person = {
+ name: string,
+ age: int,
+}
+
+let greet = person =>
+ switch person.age {
+ | age if age < 18 => "Hi " ++ person.name
+ | _ => "Hello " ++ person.name
+ }
+
+let message = greet({name: "Ada", age: 36})
+Console.log(message)`
+
+let tabLabel = tab =>
+ switch tab {
+ | Parsetree => "parsetree"
+ | Typedtree => "typedtree"
+ | Lambda => "lambda"
+ | Lam => "lam"
+ | JavaScript => "js"
+ | Settings => "settings"
+ }
+
+let statusLabel = status =>
+ switch status {
+ | Loading => "loading compiler"
+ | Ready => "ready"
+ | Compiling => "compiling"
+ | Failed(_) => "compiler error"
+ }
+
+let jsErrorMessage = obj =>
+ switch JsExn.message(obj) {
+ | Some(message) => message
+ | None => "Unknown JavaScript error"
+ }
+
+let insertTabIndent = (event: Dom.event): option =>
+ if event->Event.key !== "Tab" {
+ None
+ } else {
+ let target = event->Event.target
+ let value = target->EventTarget.value
+ let start = target->EventTarget.selectionStart
+ let end_ = target->EventTarget.selectionEnd
+ let nextValue =
+ value->String.slice(~start=0, ~end=start) ++ " " ++ value->String.slice(~start=end_)
+ let cursor = start + 2
+
+ event->Event.preventDefault
+ target->EventTarget.setValue(nextValue)
+ target->EventTarget.setSelectionRange(cursor, cursor)
+
+ Some(nextValue)
+ }
+
+let configureSourceEditor = (scrollHandler: Dom.event => unit): unit =>
+ Window.requestAnimationFrame(() =>
+ switch Document.current->Document.getElementById("source-editor") {
+ | None => ()
+ | Some(editor) =>
+ editor->Element.setAttribute("wrap", "off")
+ switch editor->Element.getScrollHandler {
+ | Some(existingHandler) if existingHandler === scrollHandler => ()
+ | existingHandler =>
+ switch existingHandler {
+ | Some(existingHandler) => editor->Element.removeEventListener("scroll", existingHandler)
+ | None => ()
+ }
+ editor->Element.setScrollHandler(scrollHandler)
+ editor->Element.addEventListener("scroll", scrollHandler)
+ }
+ }
+ )
+
+let lineNumbersText = source => {
+ let lineCount = source->String.split("\n")->Array.length
+ Array.make(~length=lineCount, 0)
+ ->Array.mapWithIndex((_, index) => (index + 1)->Int.toString)
+ ->Array.join("\n")
+}
+
+let cursorPositionForOffset = (source, offset): sourcePosition => {
+ let sourceLength = String.length(source)
+ let boundedOffset = if offset < 0 {
+ 0
+ } else if offset > sourceLength {
+ sourceLength
+ } else {
+ offset
+ }
+
+ let rec walk = (index, line, col) =>
+ if index >= boundedOffset {
+ {line, col}
+ } else if source->String.charAt(index) === "\n" {
+ walk(index + 1, line + 1, 0)
+ } else {
+ walk(index + 1, line, col + 1)
+ }
+
+ walk(0, 1, 0)
+}
+
+let editorShellStyle = (activeLine, scrollTop, scrollLeft) => {
+ let activeLineIndex = activeLine <= 1 ? 0 : activeLine - 1
+ let activeLineTop = 18 + activeLineIndex * 22 - scrollTop
+ `--active-line-top: ${activeLineTop->Int.toString}px; --editor-scroll-y: -${scrollTop->Int.toString}px; --editor-scroll-x: -${scrollLeft->Int.toString}px;`
+}
+
+let hasFeature = (features: array, feature: experimentalFeature) =>
+ features->Array.includes(feature)
+
+let toggleFeature = (features: array, feature: experimentalFeature) =>
+ hasFeature(features, feature)
+ ? features->Array.filter(item => item !== feature)
+ : Array.concat(features, [feature])
+
+let selectedOutput = (result: option, activeTab: tab) =>
+ switch result {
+ | None => "The compiler is loading. Results will appear here after the first compile."
+ | Some(Error(result)) =>
+ let errors = result.errors->Array.join("\n")
+ errors === "" ? result.message : errors
+ | Some(Ok(result)) =>
+ switch activeTab {
+ | Parsetree => result.parsetree
+ | Typedtree => result.typedtree
+ | Lambda => result.lambda
+ | Lam => result.lam
+ | JavaScript => result.jsCode
+ | Settings => ""
+ }
+ }
+
+let resultSummary = (result: option) =>
+ switch result {
+ | None => "No compile result yet"
+ | Some(Ok(result)) =>
+ let warningCount = result.warnings->Array.length
+ let warningText = warningCount === 0 ? "no warnings" : `${warningCount->Int.toString} warnings`
+ `Compiled in ${result.time->Float.toFixed(~digits=1)}ms with ${warningText}`
+ | Some(Error(result)) => result.message
+ }
+
+module TabButton = {
+ @jsx.component
+ let make = (~tab, ~activeTab: Signal.t, ~onSelect: tab => unit) => {
+
+ }
+}
+
+module Problems = {
+ @jsx.component
+ let make = (~compileResult: Signal.t