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>) => { +
+
{Node.text("Problems")}
+
+        {Node.signalText(() =>
+          switch Signal.get(compileResult) {
+          | Some(Ok({warnings})) if warnings->Array.length > 0 => warnings->Array.join("\n")
+          | Some(Error({warnings})) if warnings->Array.length > 0 => warnings->Array.join("\n")
+          | Some(Error({errors})) if errors->Array.length > 0 => errors->Array.join("\n")
+          | Some(Error({message})) => message
+          | _ => "No problems reported."
+          }
+        )}
+      
+
+ } +} + +module SettingsPanel = { + @jsx.component + let make = ( + ~activeTab: Signal.t, + ~compilerInfo: Signal.t>, + ~config: Signal.t, + ~switchCompiler: string => unit, + ~compileNow: unit => unit, + ~scheduleCompile: unit => unit, + ~scheduleUrlSync: unit => unit, + ) => { + let updateConfig = f => Signal.update(config, f) + +
+ Signal.get(activeTab) === Settings ? "settings-panel" : "settings-panel hidden-panel"} + > +
+ + +
+
+ +
+ {Node.signalText(() => + switch Signal.get(compilerInfo) { + | Some(info) => `${info.version} / API ${info.apiVersion} / ${info.bundleId}` + | None => "loading" + } + )} +
+
+
+ + +
+
+ + Signal.get(config).warnFlags} + spellcheck=false + onInput={event => { + updateConfig(config => {...config, warnFlags: Event.value(event)}) + scheduleUrlSync() + scheduleCompile() + }} + /> + +
+
+ Signal.get(config).jsxPreserveMode} + onChange={event => { + updateConfig(config => {...config, jsxPreserveMode: Event.checked(event)}) + scheduleUrlSync() + compileNow() + }} + /> + +
+
+ Signal.get(config).experimentalFeatures->hasFeature(LetUnwrap)} + onChange={_ => { + updateConfig(config => { + ...config, + experimentalFeatures: toggleFeature(config.experimentalFeatures, LetUnwrap), + }) + scheduleUrlSync() + compileNow() + }} + /> + +
+
+ +
+ {Node.signalText(() => + switch Signal.get(compilerInfo) { + | Some(info) => info.libraries->Array.join(", ") + | None => "loading" + } + )} +
+
+
+ } +} + +module StatusBadge = { + @jsx.component + let make = (~status: Signal.t) => { +
+ switch Signal.get(status) { + | Failed(_) => "status status-error" + | Compiling | Loading => "status status-busy" + | Ready => "status" + }} + > + {Node.signalText(() => + switch Signal.get(status) { + | Failed(message) => message + | other => statusLabel(other) + } + )} +
+ } +} + +module App = { + @jsx.component + let make = () => { + let source = Signal.make(defaultSource) + let activeTab = Signal.make(JavaScript) + let status = Signal.make(Loading) + let compilerInfo: Signal.t> = Signal.make(None) + let compileResult: Signal.t> = Signal.make(None) + let config = Signal.make(CompilerApi.defaultConfig) + let activeLine = Signal.make(1) + let editorScrollTop = Signal.make(0) + let editorScrollLeft = Signal.make(0) + let highlightedSource: Signal.t> = Obj.magic( + Computed.make(() => SourceHighlight.render(Signal.get(source))), + ) + let timerId: ref> = ref(None) + let urlTimerId: ref> = ref(None) + let toastTimerId: ref> = ref(None) + let firstLoadConfig: ref> = ref(None) + let compilerLoadSequence = ref(0) + let compileSequence = ref(0) + let shareToast: Signal.t> = Signal.make(None) + + let syncEditorState = event => { + let currentSource = Event.value(event) + let cursorPosition = cursorPositionForOffset(currentSource, Event.selectionStart(event)) + + Signal.set(editorScrollTop, Event.scrollTop(event)) + Signal.set(editorScrollLeft, Event.scrollLeft(event)) + Signal.set(activeLine, cursorPosition.line) + } + + let syncEditorScroll = event => { + Signal.set(editorScrollTop, Event.scrollTop(event)) + Signal.set(editorScrollLeft, Event.scrollLeft(event)) + } + + let compileNow = () => { + compileSequence := compileSequence.contents + 1 + let sequence = compileSequence.contents + + let run = async () => { + switch Signal.peek(status) { + | Loading => () + | Failed(_) => () + | Ready | Compiling => + Signal.set(status, Compiling) + try { + let result = await CompilerApi.compile(Signal.peek(source), Signal.peek(config)) + if sequence === compileSequence.contents { + Signal.set(compileResult, Some(result)) + Signal.set(status, Ready) + } + } catch { + | JsExn(obj) => + if sequence === compileSequence.contents { + Signal.set(status, Failed(jsErrorMessage(obj))) + } + | _ => + if sequence === compileSequence.contents { + Signal.set(status, Failed("Compilation failed")) + } + } + } + } + + run()->ignore + } + + let scheduleCompile = () => { + switch timerId.contents { + | Some(id) => Window.clearTimeout(id) + | None => () + } + timerId := Some(Window.setTimeout(compileNow, 280)) + } + + let syncUrlNow = () => + UrlState.replace(~source=Signal.peek(source), ~config=Signal.peek(config))->Promise.ignore + + let scheduleUrlSync = () => { + switch urlTimerId.contents { + | Some(id) => Window.clearTimeout(id) + | None => () + } + urlTimerId := Some(Window.setTimeout(syncUrlNow, 360)) + } + + let formatSource = () => { + compileSequence := compileSequence.contents + 1 + let sequence = compileSequence.contents + let sourceBeforeFormat = Signal.peek(source) + + let run = async () => { + switch Signal.peek(status) { + | Loading => () + | Failed(_) => () + | Ready | Compiling => + Signal.set(status, Compiling) + try { + switch await CompilerApi.format(sourceBeforeFormat, Signal.peek(config)) { + | Ok(formattedSource) => + if sequence === compileSequence.contents { + if Signal.peek(source) === sourceBeforeFormat { + Signal.set(source, formattedSource) + Signal.set(activeLine, 1) + Signal.set(editorScrollTop, 0) + Signal.set(editorScrollLeft, 0) + scheduleUrlSync() + Signal.set(status, Ready) + compileNow() + } else { + Signal.set(status, Ready) + } + } + | Error(failure) => + if sequence === compileSequence.contents { + Signal.set(compileResult, Some(Error(failure))) + Signal.set(status, Ready) + } + } + } catch { + | JsExn(obj) => + if sequence === compileSequence.contents { + Signal.set(status, Failed(jsErrorMessage(obj))) + } + | _ => + if sequence === compileSequence.contents { + Signal.set(status, Failed("Formatting failed")) + } + } + } + } + + run()->ignore + } + + let showToast = message => { + switch toastTimerId.contents { + | Some(id) => Window.clearTimeout(id) + | None => () + } + Signal.set(shareToast, Some(message)) + toastTimerId := Some(Window.setTimeout(() => Signal.set(shareToast, None), 1800)) + } + + let shareCurrentUrl = () => { + switch urlTimerId.contents { + | Some(id) => Window.clearTimeout(id) + | None => () + } + + let share = async () => { + switch await UrlState.copy(~source=Signal.peek(source), ~config=Signal.peek(config)) { + | Ok() => showToast("Link copied") + | Error(message) => showToast(message) + } + } + + share()->Promise.ignore + } + + let loadCompiler = (version, compileAfterLoad) => { + compilerLoadSequence := compilerLoadSequence.contents + 1 + compileSequence := compileSequence.contents + 1 + let sequence = compilerLoadSequence.contents + + let load = async () => { + try { + Signal.set(status, Loading) + Signal.set(compileResult, None) + let info = await CompilerApi.init(version) + if sequence === compilerLoadSequence.contents { + let firstLoadConfigValue = firstLoadConfig.contents + firstLoadConfig := None + let nextConfig = switch firstLoadConfigValue { + | Some(config) => {...config, compilerVersion: info.bundleId} + | None => { + PlaygroundConfig.compilerVersion: info.bundleId, + moduleSystem: info.moduleSystem, + warnFlags: info.warnFlags, + jsxPreserveMode: info.jsxPreserveMode, + experimentalFeatures: info.experimentalFeatures, + } + } + Signal.set(compilerInfo, Some(info)) + Signal.set(config, nextConfig) + Signal.set(status, Ready) + switch firstLoadConfigValue { + | Some(_) => () + | None => scheduleUrlSync() + } + if compileAfterLoad { + compileNow() + } + } + } catch { + | JsExn(obj) => + if sequence === compilerLoadSequence.contents { + Signal.set(status, Failed(jsErrorMessage(obj))) + } + | _ => + if sequence === compilerLoadSequence.contents { + Signal.set(status, Failed("Compiler failed to load")) + } + } + } + + load()->ignore + } + + let switchCompiler = version => loadCompiler(version, true) + + Effect.run(() => { + let start = async () => { + let urlState = await UrlState.init(~defaultSource) + Signal.set(source, urlState.source) + Signal.set(config, urlState.config) + Signal.set(activeLine, 1) + Signal.set(editorScrollTop, 0) + Signal.set(editorScrollLeft, 0) + firstLoadConfig := Some(urlState.config) + loadCompiler(urlState.config.compilerVersion, true) + } + + start()->ignore + None + }) + + Effect.run(() => { + configureSourceEditor(syncEditorScroll) + None + }) + +
+
+
+

{Node.text("ReScript Developer Playground")}

+
+ +
+
+
+
+

{Node.text("Source")}

+
+ + + +
+
+
+ editorShellStyle( + Signal.get(activeLine), + Signal.get(editorScrollTop), + Signal.get(editorScrollLeft), + )} + > +
+
+
+                {Node.signalText(() => lineNumbersText(Signal.get(source)))}
+              
+
+
+              {Node.signalFragment(highlightedSource)}
+            
+