diff --git a/Makefile b/Makefile
index 764812c..82ce157 100644
--- a/Makefile
+++ b/Makefile
@@ -70,3 +70,11 @@ build-example-ok:
.PHONY: build-example
build-example: ## Builds the example
$(DUNE) build @build-example
+
+run-build:
+ rm -rf build
+ MODE=build commands/reshowcase2 ./_build/default/newexample/newexample/newexample/NewDemo.js
+
+run-watch:
+ rm -rf build
+ MODE=watch commands/reshowcase2 ./_build/default/newexample/newexample/newexample/NewDemo.js
diff --git a/commands/node-loader.js b/commands/node-loader.js
new file mode 100644
index 0000000..5d1d994
--- /dev/null
+++ b/commands/node-loader.js
@@ -0,0 +1,27 @@
+import { fileURLToPath } from "url";
+import { basename } from "path";
+
+function getFilenameWithoutExtension(filePath) {
+ return basename(filePath, ".js"); // or use extname to auto-detect
+}
+
+export async function load(url, context, nextLoad) {
+ if (url.includes("_Doc")) {
+ const filePath = fileURLToPath(url);
+
+ const filename = getFilenameWithoutExtension(filePath);
+
+ const stubModule = `
+ export const modulePath = "${filePath}";
+ export const demoName = "${filename}";
+ `;
+
+ return {
+ format: "module",
+ source: stubModule,
+ shortCircuit: true,
+ };
+ }
+
+ return nextLoad(url, context);
+}
diff --git a/commands/polyfill.js b/commands/polyfill.js
new file mode 100644
index 0000000..574c7d5
--- /dev/null
+++ b/commands/polyfill.js
@@ -0,0 +1,4 @@
+// polyfill.js
+if (typeof window === "undefined") {
+ global.window = {};
+}
diff --git a/commands/reshowcase2 b/commands/reshowcase2
new file mode 100755
index 0000000..2e2f8ee
--- /dev/null
+++ b/commands/reshowcase2
@@ -0,0 +1,5 @@
+#!/bin/bash
+# node_custom_bin
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec node --require "$SCRIPT_DIR/polyfill.js" "$@"
diff --git a/dune b/dune
index 67071b8..0c3fbac 100644
--- a/dune
+++ b/dune
@@ -1,4 +1,4 @@
-(dirs commands example src tests)
+(dirs commands example newexample src tests)
(install
(section bin)
@@ -7,6 +7,9 @@
commands/demo-template.html
commands/favicon.png
commands/reshowcase
+ commands/reshowcase2
+ commands/node-loader.js
+ commands/polyfill.js
commands/ui-template.html))
(rule
diff --git a/dune-project b/dune-project
index 15f7633..33ed428 100644
--- a/dune-project
+++ b/dune-project
@@ -28,6 +28,8 @@
ocaml
(melange
(>= 5.0.1))
+ (melange-json
+ (>= 2.0.0))
(reason
(>= 3.11.0))
(reason-react
diff --git a/example/Demo.re b/example/Demo.re
index 877d3da..f607a5d 100644
--- a/example/Demo.re
+++ b/example/Demo.re
@@ -113,7 +113,15 @@ demo(({addDemo: _, addCategory}) =>
addCategory("Headings", ({addDemo, addCategory: _}) => {
addDemo("H1", ({string, int, _}) => {
let size =
- int("Font size", {min: 0, max: 100, initial: 30, step: 1});
+ int(
+ "Font size",
+ {
+ min: 0,
+ max: 100,
+ initial: 30,
+ step: 1,
+ },
+ );
{string("Text", "hello")->React.string}
diff --git a/newexample/.ocamlformat b/newexample/.ocamlformat
new file mode 100644
index 0000000..e69de29
diff --git a/newexample/ButtonHuge_Doc.re b/newexample/ButtonHuge_Doc.re
new file mode 100644
index 0000000..7db6efe
--- /dev/null
+++ b/newexample/ButtonHuge_Doc.re
@@ -0,0 +1,62 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "Huge";
+
+let spaceConcat = (x1, x2) =>
+ switch (x1, x2) {
+ | ("", x)
+ | (x, "") => x
+ | (x1, x2) => x1 ++ " " ++ x2
+ };
+
+let (+++) = spaceConcat;
+
+module Cn = {
+ let ifTrue = (cn, x) => x ? cn : "";
+};
+
+module Css = {
+ let button = [%cx
+ {|
+ color: #fff;
+ border: none;
+ padding: 10px;
+ border-radius: 10px;
+ font-family: inherit;
+ font-size: inherit;
+ |}
+ ];
+
+ let buttonHuge = [%cx {|
+ padding: 20px;
+ font-size: 30px;
+ |}];
+
+ let buttonDisabled = [%cx {|
+ cursor: default;
+ opacity: 0.5;
+ |}];
+
+ let buttonColor = color => {
+ let color = `hex(color);
+ [%cx {|
+ background-color: $(color);
+ |}];
+ };
+};
+
+[@react.component]
+let make = () => {
+ let disabled = false;
+ let color = "0091FF";
+
+ ;
+};
diff --git a/newexample/ButtonNormal_Doc.re b/newexample/ButtonNormal_Doc.re
new file mode 100644
index 0000000..911e33c
--- /dev/null
+++ b/newexample/ButtonNormal_Doc.re
@@ -0,0 +1,56 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "Normal";
+
+let spaceConcat = (x1, x2) =>
+ switch (x1, x2) {
+ | ("", x)
+ | (x, "") => x
+ | (x1, x2) => x1 ++ " " ++ x2
+ };
+
+let (+++) = spaceConcat;
+
+module Cn = {
+ let ifTrue = (cn, x) => x ? cn : "";
+};
+
+module Css = {
+ let button = [%cx
+ {|
+ color: #fff;
+ border: none;
+ padding: 10px;
+ border-radius: 10px;
+ font-family: inherit;
+ font-size: inherit;
+ |}
+ ];
+
+ let buttonDisabled = [%cx {|
+ cursor: default;
+ opacity: 0.5;
+ |}];
+
+ let buttonColor = color => {
+ let color = `hex(color);
+ [%cx {|
+ background-color: $(color);
+ |}];
+ };
+};
+
+[@react.component]
+let make = () => {
+ let disabled = false;
+ let color = "0091FF";
+
+ ;
+};
diff --git a/newexample/CodeExample_Doc.re b/newexample/CodeExample_Doc.re
new file mode 100644
index 0000000..7d64e23
--- /dev/null
+++ b/newexample/CodeExample_Doc.re
@@ -0,0 +1,46 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "Code example";
+
+module Css = {
+ let code = [%cx
+ {|
+ white-space: pre;
+ padding: 0;
+ background-color: #f5f6f6;
+ |}
+ ];
+};
+
+[@react.component]
+let make = () => {
+
+ {js|open Reshowcase.Entry;
+
+demo(({addDemo: _, addCategory}) =>
+ addCategory("Typography", ({addDemo: _, addCategory}) => {
+ addCategory("Headings", ({addDemo, addCategory: _}) => {
+ addDemo("H1", ({string, int, _}) => {
+ let size =
+ int("Font size", {min: 0, max: 100, initial: 30, step: 1});
+
+
+ {string("Text", "hello")->React.string}
+
;
+ });
+ addDemo("H2", ({string, _}) =>
+ {string("Text", "hello")->React.string}
+ );
+ });
+ addCategory("Text", ({addDemo, addCategory: _}) => {
+ addDemo("Paragraph", ({string, _}) =>
+ {string("Text", "hello")->React.string}
+ );
+ addDemo("Italic", ({string, _}) =>
+ {string("Text", "hello")->React.string}
+ );
+ });
+ })
+);|js}
+ ->React.string
+ ;
+};
diff --git a/newexample/H1_Doc.re b/newexample/H1_Doc.re
new file mode 100644
index 0000000..a99cfb2
--- /dev/null
+++ b/newexample/H1_Doc.re
@@ -0,0 +1,18 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "H1";
+
+module Css = {
+ let h1Size = size => {
+ let fontSize = `px(size);
+ [%cx {|
+ font-size: $(fontSize);
+ |}];
+ };
+};
+
+[@react.component]
+let make = () => {
+ let size = 30;
+
+ {React.string("hello")}
;
+};
diff --git a/newexample/H2_Doc.re b/newexample/H2_Doc.re
new file mode 100644
index 0000000..00bc7de
--- /dev/null
+++ b/newexample/H2_Doc.re
@@ -0,0 +1,7 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "H2";
+
+[@react.component]
+let make = () => {
+ {React.string("hello")}
;
+};
diff --git a/newexample/Italic_Doc.re b/newexample/Italic_Doc.re
new file mode 100644
index 0000000..d51937c
--- /dev/null
+++ b/newexample/Italic_Doc.re
@@ -0,0 +1,7 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "Italic";
+
+[@react.component]
+let make = () => {
+ {React.string("hello")} ;
+};
diff --git a/newexample/NewDemo.re b/newexample/NewDemo.re
new file mode 100644
index 0000000..75c82e7
--- /dev/null
+++ b/newexample/NewDemo.re
@@ -0,0 +1,73 @@
+let items: array(Reshowcase.NewEntity.item) = [|
+ Category({
+ name: "Buttons",
+ items: [|
+ Demo({
+ name: ButtonNormal_Doc.demoName,
+ modulePath: ButtonNormal_Doc.modulePath,
+ }),
+ Demo({
+ name: ButtonHuge_Doc.demoName,
+ modulePath: ButtonHuge_Doc.modulePath,
+ }),
+ |],
+ }),
+ Category({
+ name: "Headings",
+ items: [|
+ Demo({
+ name: H1_Doc.demoName,
+ modulePath: H1_Doc.modulePath,
+ }),
+ Demo({
+ name: H2_Doc.demoName,
+ modulePath: H2_Doc.modulePath,
+ }),
+ |],
+ }),
+ Category({
+ name: "Text",
+ items: [|
+ Demo({
+ name: Paragraph_Doc.demoName,
+ modulePath: Paragraph_Doc.modulePath,
+ }),
+ Demo({
+ name: Italic_Doc.demoName,
+ modulePath: Italic_Doc.modulePath,
+ }),
+ Category({
+ name: "Nested Text",
+ items: [|
+ Demo({
+ name: Italic_Doc.demoName,
+ modulePath: Italic_Doc.modulePath,
+ }),
+ |],
+ }),
+ |],
+ }),
+ Demo({
+ name: CodeExample_Doc.demoName,
+ modulePath: CodeExample_Doc.modulePath,
+ }),
+ Category({
+ name: "Test search",
+ items: [|
+ Demo({
+ name: OneTwoThreeFour_Doc.demoName,
+ modulePath: OneTwoThreeFour_Doc.modulePath,
+ }),
+ Demo({
+ name: OneTwoThreeFive_Doc.demoName,
+ modulePath: OneTwoThreeFive_Doc.modulePath,
+ }),
+ Demo({
+ name: OneTwoFourSeven_Doc.demoName,
+ modulePath: OneTwoFourSeven_Doc.modulePath,
+ }),
+ |],
+ }),
+|];
+
+let () = Reshowcase.NewEntry.start(~items, ~outputDir="./build", ());
diff --git a/newexample/OneTwoFourSeven_Doc.re b/newexample/OneTwoFourSeven_Doc.re
new file mode 100644
index 0000000..c474bef
--- /dev/null
+++ b/newexample/OneTwoFourSeven_Doc.re
@@ -0,0 +1,5 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "OneTwoFourSeven";
+
+[@react.component]
+let make = () => React.null;
diff --git a/newexample/OneTwoThreeFive_Doc.re b/newexample/OneTwoThreeFive_Doc.re
new file mode 100644
index 0000000..bbc3523
--- /dev/null
+++ b/newexample/OneTwoThreeFive_Doc.re
@@ -0,0 +1,5 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "OneTwoThreeFive";
+
+[@react.component]
+let make = () => React.null;
diff --git a/newexample/OneTwoThreeFour_Doc.re b/newexample/OneTwoThreeFour_Doc.re
new file mode 100644
index 0000000..0c856c1
--- /dev/null
+++ b/newexample/OneTwoThreeFour_Doc.re
@@ -0,0 +1,5 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "OneTwoThreeFour";
+
+[@react.component]
+let make = () => React.null;
diff --git a/newexample/Paragraph_Doc.re b/newexample/Paragraph_Doc.re
new file mode 100644
index 0000000..fc386ea
--- /dev/null
+++ b/newexample/Paragraph_Doc.re
@@ -0,0 +1,7 @@
+let modulePath = Reshowcase.Utils.getFilepath();
+let demoName = "Paragraph";
+
+[@react.component]
+let make = () => {
+
{React.string("hello")}
;
+};
diff --git a/newexample/dune b/newexample/dune
new file mode 100644
index 0000000..4ca41a6
--- /dev/null
+++ b/newexample/dune
@@ -0,0 +1,7 @@
+(melange.emit
+ (target newexample)
+ (alias newexample)
+ (libraries reshowcase reason-react styled-ppx.melange)
+ (module_systems es6)
+ (preprocess
+ (pps melange.ppx reason-react-ppx styled-ppx)))
diff --git a/reshowcase.opam b/reshowcase.opam
index 5153a6d..dcddfc0 100644
--- a/reshowcase.opam
+++ b/reshowcase.opam
@@ -13,6 +13,7 @@ depends: [
"dune" {>= "3.16"}
"ocaml"
"melange" {>= "5.0.1"}
+ "melange-json" {>= "2.0.0"}
"reason" {>= "3.11.0"}
"reason-react" {>= "0.16.0"}
"reason-react-ppx" {>= "0.16.0"}
diff --git a/src/Foo.re b/src/Foo.re
new file mode 100644
index 0000000..cf84315
--- /dev/null
+++ b/src/Foo.re
@@ -0,0 +1,6 @@
+[@react.component]
+let make = () => "Hello"->React.string
;
+
+let filePath = Utils.getFilepath();
+
+Js.log2("filePath", filePath);
diff --git a/src/Process.re b/src/Process.re
new file mode 100644
index 0000000..49b64ed
--- /dev/null
+++ b/src/Process.re
@@ -0,0 +1,19 @@
+type process;
+
+external process: process = "process";
+
+[@mel.send] external exit': (process, int) => 'a = "exit";
+
+[@mel.get] external argv: process => array(string) = "argv";
+
+let exit = int => process->exit'(int);
+
+let getArgs = () => process->argv;
+
+external env: Js.Dict.t(string) = "process.env";
+
+[@mel.send] external on: (process, string, unit => unit) => unit = "on";
+
+let onTerminate = callback =>
+ [|"SIGINT", "SIGTERM"|]
+ ->Js.Array.forEach(~f=signal => process->on(signal, callback), _);
diff --git a/src/ReshowcaseUi.re b/src/ReshowcaseUi.re
index f4fb10d..fb6b18a 100644
--- a/src/ReshowcaseUi.re
+++ b/src/ReshowcaseUi.re
@@ -915,7 +915,8 @@ module DemoUnitFrame = {
border: none;
height: $(height);
width: $(width);
- |}];
+ |}
+ ];
};
};
diff --git a/src/Util.re b/src/Util.re
new file mode 100644
index 0000000..93b8e2d
--- /dev/null
+++ b/src/Util.re
@@ -0,0 +1,18 @@
+// https://nodejs.org/api/util.html#utilinspectobject-options
+
+type options = {
+ depth: int,
+ colors: bool,
+};
+
+[@mel.module "node:util"]
+external inspect: ('a, options) => string = "inspect";
+
+let inspect = value =>
+ inspect(
+ value,
+ {
+ depth: 20,
+ colors: true,
+ },
+ );
diff --git a/src/Utils.re b/src/Utils.re
new file mode 100644
index 0000000..dad180c
--- /dev/null
+++ b/src/Utils.re
@@ -0,0 +1,105 @@
+type jsError;
+
+[@mel.new] external makeError: unit => jsError = "Error";
+
+[@mel.get] external getStack: jsError => string = "stack";
+
+external window: _ = "window";
+external process: _ = "process";
+
+// Commented to avoid error in webpack
+// @module("path") external dirnameFromFilepath: string => string = "dirname"
+
+let dirnameFromFilepath = filepath => {
+ filepath
+ ->Js.String.split(~sep="/", _)
+ ->Js.Array.slice(~start=0, ~end_=-1, _)
+ ->Js.Array.join(~sep="/", _);
+};
+
+// Reusable functions that can be simply called from any module instead of
+// dealing with import.meta.url etc.
+
+let getFilepathFromError = jsError => {
+ let lineWithPath =
+ jsError
+ ->getStack
+ ->Js.String.split(~sep="\n", _)
+ ->Js.Array.slice(~start=2, ~end_=3, _)
+ ->Belt.Array.get(0);
+
+ switch (lineWithPath) {
+ | None => Js.Exn.raiseError("[getFilepathFromError] lineWithPath is None")
+ | Some(lineWithPath) =>
+ lineWithPath
+ ->Js.String.trim
+ ->Js.String.replace(~search="at file://", ~replacement="", _)
+ ->Js.String.replaceByRe(
+ ~regexp=Js.Re.fromString(":[0-9]+:[0-9]+"),
+ ~replacement="",
+ _,
+ )
+ };
+};
+
+let getFilepath = () =>
+ switch (Js.typeof(process) == "undefined") {
+ // Get filepath only in node
+ | true => ""
+ | _false => makeError()->getFilepathFromError
+ };
+
+let getDirname = () => makeError()->getFilepathFromError->dirnameFromFilepath;
+
+let getModuleNameFromModulePath = modulePath => {
+ let segments = modulePath->Js.String.split(~sep="/", _);
+ let filename = segments->Belt.Array.get(Belt.Array.length(segments) - 1);
+ switch (filename) {
+ | None
+ | Some("") =>
+ Js.Console.error(
+ "[Utils.getModuleNameFromModulePath] Filename is empty or None, modulePath: "
+ ++ modulePath,
+ );
+ Process.exit(1);
+ | Some(filename) =>
+ let filenameSplit = filename->Js.String.split(~sep=".", _);
+ let moduleName = filenameSplit->Belt.Array.get(0);
+ switch (moduleName) {
+ | None =>
+ Js.Console.error(
+ "[Utils.getModuleNameFromModulePath] moduleName is None, modulePath: "
+ ++ modulePath,
+ );
+ Process.exit(1);
+ | Some(moduleName) => moduleName
+ };
+ };
+};
+
+let maybeAddSlashPrefix = path =>
+ if (path->Js.String.startsWith(~prefix="http", _)
+ || path->Js.String.startsWith(~prefix="/", _)) {
+ path;
+ } else {
+ "/" ++ path;
+ };
+
+let maybeAddSlashSuffix = path =>
+ if (path->Js.String.endsWith(~suffix="/", _)) {
+ path;
+ } else {
+ path ++ "/";
+ };
+
+let replaceByRe = (s, regexp, replacement) =>
+ Js.String.replaceByRe(~regexp, ~replacement, s);
+
+let slugify = text => {
+ text
+ ->Js.String.toLowerCase
+ ->Js.String.trim
+ ->replaceByRe([%re "/\\s+/g"], "-") // Replace spaces with `-`
+ ->replaceByRe([%re "/[^\\w-]+/g"], "") // Remove all non-word chars
+ ->replaceByRe([%re "/--+/g"], "-"); // Replace multiple `-` with single `-`
+};
diff --git a/src/dune b/src/dune
index 009d259..fc4e400 100644
--- a/src/dune
+++ b/src/dune
@@ -1,7 +1,18 @@
+(include_subdirs unqualified)
+
(library
(name reshowcase)
(preprocess
- (pps melange.ppx reason-react-ppx styled-ppx))
- (libraries melange.belt reason-react styled-ppx.melange)
+ (pps melange.ppx melange-json.ppx reason-react-ppx styled-ppx))
+ (libraries melange.belt melange-json reason-react styled-ppx.melange)
(public_name reshowcase)
(modes melange))
+
+(melange.emit
+ (target browser)
+ (alias browser)
+ (libraries reshowcase)
+ (preprocess
+ (pps melange.ppx reason-react-ppx))
+ (modules)
+ (module_systems es6))
diff --git a/src/new/Bundler.re b/src/new/Bundler.re
new file mode 100644
index 0000000..823427c
--- /dev/null
+++ b/src/new/Bundler.re
@@ -0,0 +1,48 @@
+type mode =
+ | Build
+ | Watch;
+
+let assetsDirname = "assets";
+
+let assetFileExtensions = [|
+ "css",
+ "jpg",
+ "jpeg",
+ "png",
+ "gif",
+ "svg",
+ "ico",
+ "avif",
+ "webp",
+ "woff",
+ "woff2",
+ "json",
+ "mp4",
+|];
+
+let assetFileExtensionsWithoutCss =
+ assetFileExtensions->Js.Array.filter(~f=ext => ext !== "css", _);
+
+let assetRegex = {
+ let regex: string = assetFileExtensions->Js.Array.join(~sep="|", _);
+ let regex = {|\.|} ++ "(" ++ regex ++ ")" ++ "$";
+ Js.Re.fromStringWithFlags(regex, ~flags="i");
+};
+
+let getGlobalEnvValuesDict = (globalEnvValues: array((string, string))) => {
+ let dict = Js.Dict.empty();
+
+ globalEnvValues->Js.Array.forEach(
+ ~f=
+ ((key, value)) => {
+ let value = {j|"$(value)"|j};
+ dict->Js.Dict.set(key, value);
+ },
+ _,
+ );
+
+ dict;
+};
+
+// TODO double check this
+let getOutputDir = (~outputDir) => outputDir;
diff --git a/src/new/Esbuild.re b/src/new/Esbuild.re
new file mode 100644
index 0000000..0f590b1
--- /dev/null
+++ b/src/new/Esbuild.re
@@ -0,0 +1,562 @@
+external import_: string => Js.Promise.t('a) = "import";
+
+type esbuild;
+
+type context;
+
+type buildResult = {
+ errors: array(Js.Json.t),
+ warnings: array(Js.Json.t),
+ metafile: Js.Json.t,
+};
+
+module Entry = {
+ type t = {
+ path: string,
+ entryPath: string,
+ };
+};
+
+module CustomConfig = {
+ type t = {
+ define: option(Js.Dict.t(string)),
+ loader: option(Js.Dict.t(string)),
+ publicPath: option(string),
+ minify: option(bool),
+ };
+
+ [@mel.module "node:fs"]
+ external readdirSync: string => array(string) = "readdirSync";
+
+ let readCustomConfig = (~customConfigPath: string): Promise.t(option(t)) =>
+ if (!Fs.existsSync(customConfigPath)) {
+ Promise.resolve(None);
+ } else {
+ let configFilenames = readdirSync(customConfigPath);
+ let configFilename =
+ configFilenames->Js.Array.find(~f=filename =>
+ filename == "config.cjs" || filename == "config.js"
+ );
+
+ switch (configFilename) {
+ | None => Promise.resolve(None)
+ | Some(filename) =>
+ Js.log2("reading custom config from:", filename);
+
+ let pathToConfig = Path.join2(customConfigPath, filename);
+
+ import_(pathToConfig)
+ ->Promise.map(imported => {
+ Js.log2("!!!Imported data:", imported);
+
+ let config = imported##default;
+
+ let define =
+ switch (Js.Nullable.toOption(config##define)) {
+ | None => None
+ | Some(defineValue) =>
+ switch (Js.typeof(defineValue)) {
+ | "object" => Some(defineValue)
+ | _ => None
+ }
+ };
+
+ let loader =
+ switch (Js.Nullable.toOption(config##loader)) {
+ | None => None
+ | Some(loaderValue) =>
+ switch (Js.typeof(loaderValue)) {
+ | "object" => Some(loaderValue)
+ | _ => None
+ }
+ };
+
+ let publicPath =
+ switch (Js.Nullable.toOption(config##publicPath)) {
+ | None => None
+ | Some(publicPathValue) =>
+ switch (Js.typeof(publicPathValue)) {
+ | "string" => Some(publicPathValue)
+ | _ => None
+ }
+ };
+
+ let minify =
+ switch (Js.Nullable.toOption(config##minify)) {
+ | None => None
+ | Some(minifyValue) =>
+ switch (Js.typeof(minifyValue)) {
+ | "boolean" => Some(minifyValue)
+ | _ => None
+ }
+ };
+
+ Some({
+ define,
+ loader,
+ publicPath,
+ minify,
+ });
+ })
+ ->Promise.catch(error => {
+ Js.Console.error2("Failed to read config:", error);
+ Promise.resolve(None);
+ });
+ };
+ };
+};
+
+module Plugin = {
+ // https://esbuild.github.io/plugins/#on-start
+
+ type buildCallbacks = {
+ onStart: (unit => unit) => unit,
+ onEnd: (buildResult => unit) => unit,
+ };
+
+ type t = {
+ name: string,
+ setup: buildCallbacks => unit,
+ };
+
+ let watchModePlugin = {
+ name: "watchPlugin",
+ setup: buildCallbacks => {
+ buildCallbacks.onEnd(_buildResult =>
+ Js.log("[Esbuild] Rebuild finished!")
+ );
+ },
+ };
+};
+
+[@mel.module "esbuild"] external esbuild: esbuild = "default";
+
+[@mel.send]
+external build': (esbuild, Js.t('a)) => Promise.t(buildResult) = "build";
+
+[@mel.send]
+external context: (esbuild, Js.t('a)) => Promise.t(context) = "context";
+
+[@mel.send] external watch: (context, unit) => Promise.t(unit) = "watch";
+
+[@mel.send] external dispose: (context, unit) => Promise.t(unit) = "dispose";
+
+// https://esbuild.github.io/api/#serve-arguments
+type serveOptions = {
+ port: int,
+ servedir: option(string),
+};
+
+// https://esbuild.github.io/api/#serve-return-values
+type serveResult = {
+ host: string,
+ port: int,
+};
+
+[@mel.send]
+external serve: (context, serveOptions) => Promise.t(serveResult) = "serve";
+
+module HtmlPlugin = {
+ // https://github.com/craftamap/esbuild-plugin-html/blob/b74debfe7f089a4f073f5a0cf9bbdb2e59370a7c/src/index.ts#L8
+ type options = {files: array(htmlFileConfiguration)}
+ and htmlFileConfiguration = {
+ filename: string,
+ entryPoints: array(string),
+ htmlTemplate: string,
+ scriptLoading: string,
+ };
+
+ [@mel.module "@craftamap/esbuild-plugin-html"]
+ external make: (. options) => Plugin.t = "htmlPlugin";
+};
+
+module LogLevel = {
+ // https://esbuild.github.io/api/#log-level
+ type t =
+ | Silent
+ | Error
+ | Warning
+ | Info
+ | Debug;
+
+ let toString = (t: t) =>
+ switch (t) {
+ | Silent => "silent"
+ | Error => "error"
+ | Warning => "warning"
+ | Info => "info"
+ | Debug => "debug"
+ };
+};
+
+let hotReloadScript = {js|
+
+|js};
+
+let makeAppHtmlTemplate = (~withHotReloadScript) => {
+ let hotReloadScript = withHotReloadScript ? hotReloadScript : "";
+ {j|
+
+
+
+
+ Reshowcase
+
+
+ $(hotReloadScript)
+
+
+
+
+
+
+|j};
+};
+
+let makeDemoHtmlTemplate = () => {
+ {js|
+
+
+
+
+ Reshowcase demo
+
+
+
+
+
+
+
+|js};
+};
+
+let mergeDicts = (dict1, dict2) => {
+ Js.Array.concat(~other=Js.Dict.entries(dict2), Js.Dict.entries(dict1))
+ ->Js.Dict.fromArray;
+};
+
+let makeConfig =
+ (
+ ~demoHtmlTemplatePath: option(string),
+ ~mode: Bundler.mode,
+ ~outputDir: string,
+ ~projectRootDir: string,
+ ~globalEnvValues: array((string, string)),
+ ~entries: array(Entry.t),
+ ~logOverride: Js.Dict.t(LogLevel.t),
+ ~logLevel: LogLevel.t,
+ ~logLimit: int,
+ ~customConfig: option(CustomConfig.t),
+ ) => {
+ Js.log2("!!! customConfig:", customConfig);
+
+ {
+ // https://esbuild.github.io/api/
+
+ "entryPoints":
+ entries->Js.Array.map(~f=(page: Entry.t) => page.entryPath, _),
+ "entryNames": Bundler.assetsDirname ++ "/" ++ "js/[dir]/[name]-[hash]",
+ "chunkNames": Bundler.assetsDirname ++ "/" ++ "js/_chunks/[name]-[hash]",
+ "assetNames": Bundler.assetsDirname ++ "/" ++ "[name]-[hash]",
+ "outdir": Bundler.getOutputDir(~outputDir),
+ "publicPath": {
+ let customPublicPath =
+ switch (customConfig) {
+ | None => None
+ | Some(config) => config.publicPath
+ };
+
+ let publicPath =
+ switch (customPublicPath) {
+ | Some(publicPath) => publicPath
+ | None => "/"
+ };
+
+ Js.log2("!!! publicPath:", publicPath);
+ publicPath;
+ },
+ // TODO Look at this
+ "format": "esm",
+ "bundle": true,
+ "minify": {
+ let customMinify =
+ switch (customConfig) {
+ | None => None
+ | Some(config) => config.minify
+ };
+
+ switch (customMinify) {
+ | Some(minify) => minify
+ | None =>
+ switch (mode) {
+ | Build => true
+ | Watch => false
+ }
+ };
+ },
+ "metafile": true,
+ "splitting": true,
+ "treeShaking": true,
+ "logLimit": logLimit,
+ "logLevel": logLevel->LogLevel.toString,
+ "logOverride": {
+ let logOverride: Js.Dict.t(string) =
+ logOverride
+ ->Js.Dict.entries
+ ->Js.Array.map(
+ ~f=((error, logLevel)) => (error, logLevel->LogLevel.toString),
+ _,
+ )
+ ->Js.Dict.fromArray;
+ logOverride;
+ },
+ "define": {
+ let defaultDefine = Bundler.getGlobalEnvValuesDict(globalEnvValues);
+
+ let customDefine =
+ switch (customConfig) {
+ | None => None
+ | Some(config) => config.define
+ };
+
+ switch (customDefine) {
+ | None => defaultDefine
+ | Some(custom) => mergeDicts(defaultDefine, custom)
+ };
+ },
+ "loader": {
+ let customLoader =
+ switch (customConfig) {
+ | None => None
+ | Some(config) => config.loader
+ };
+
+ switch (customLoader) {
+ | Some(loader) => loader
+ | None =>
+ Bundler.assetFileExtensionsWithoutCss
+ ->Js.Array.map(~f=ext => {("." ++ ext, "file")}, _)
+ ->Js.Dict.fromArray
+ };
+ },
+ "plugins": {
+ // entryPoint must be relative path to the root of user's project
+ // filename field, which if actually a path will be relative to "outdir".
+ let htmlPluginFiles =
+ entries->Js.Array.map(
+ ~f=
+ (renderedPage: Entry.t) => {
+ let entryPathRelativeToProjectRoot =
+ Path.relative(
+ ~from=projectRootDir,
+ ~to_=renderedPage.entryPath,
+ );
+
+ let isDemoEntry =
+ Js.String.includes(
+ ~search="iframe",
+ renderedPage.path,
+ );
+
+ let htmlTemplate =
+ switch (isDemoEntry) {
+ | false =>
+ makeAppHtmlTemplate(
+ ~withHotReloadScript={
+ switch (mode) {
+ | Watch => true
+ | Build => false
+ };
+ },
+ )
+ | true =>
+ switch (demoHtmlTemplatePath) {
+ | None => makeDemoHtmlTemplate()
+ | Some(path) => Fs.readFileSyncAsUtf8(path)
+ }
+ };
+
+ {
+ HtmlPlugin.filename:
+ Path.join2(renderedPage.path, "index.html"),
+ entryPoints: [|entryPathRelativeToProjectRoot|],
+ htmlTemplate,
+ scriptLoading: "module",
+ };
+ },
+ _,
+ );
+
+ let htmlPlugin = HtmlPlugin.make(. {files: htmlPluginFiles});
+
+ switch (mode) {
+ | Build => [|htmlPlugin|]
+ | Watch => [|htmlPlugin, Plugin.watchModePlugin|]
+ };
+ },
+ };
+};
+
+let build =
+ (
+ ~outputDir: string,
+ ~projectRootDir: string,
+ ~globalEnvValues: array((string, string)),
+ ~entries: array(Entry.t),
+ ~logLevel: LogLevel.t=Warning,
+ ~logOverride: Js.Dict.t(LogLevel.t)=Js.Dict.empty(),
+ ~customConfig: option(CustomConfig.t),
+ ~demoHtmlTemplatePath: option(string)=?,
+ (),
+ )
+ : Js.Promise.t(unit) => {
+ Js.log("[Esbuild] Bundling...");
+
+ let startTime = Performance.now();
+
+ let config =
+ makeConfig(
+ ~mode=Build,
+ ~outputDir,
+ ~projectRootDir,
+ ~globalEnvValues,
+ ~entries,
+ ~logLevel,
+ ~logOverride,
+ ~logLimit=10,
+ ~customConfig,
+ ~demoHtmlTemplatePath,
+ );
+
+ esbuild
+ ->build'(config)
+ ->Promise.map(_buildResult => {
+ // let json =
+ // Js.Json.stringifyAny(_buildResult.metafile)
+ // ->Belt.Option.getWithDefault("");
+ // Fs.writeFileSync(~path=Path.join2(outputDir, "meta.json"), ~data=json);
+ Js.log2(
+ "[Esbuild] Success! Duration:",
+ Performance.durationSinceStartTime(~startTime),
+ )
+ })
+ ->Promise.catch(error => {
+ Js.Console.error2(
+ "[Esbuild] Build failed! Promise.catch:",
+ error->Util.inspect,
+ );
+ Process.exit(1);
+ });
+};
+
+let watchAndServe =
+ (
+ ~outputDir,
+ ~projectRootDir: string,
+ ~globalEnvValues: array((string, string)),
+ ~entries: array(Entry.t),
+ ~port: int,
+ ~logLevel: LogLevel.t=Warning,
+ ~logOverride: Js.Dict.t(LogLevel.t)=Js.Dict.empty(),
+ ~logLimit=10,
+ ~customConfig: option(CustomConfig.t),
+ ~demoHtmlTemplatePath: option(string)=?,
+ (),
+ )
+ : Promise.t(serveResult) => {
+ Js.log("[Esbuild] Starting esbuild...");
+ let watchDurationLabel = "[Esbuild] Watch mode started! Duration";
+ let serveDurationLabel = "[Esbuild] Serve mode started! Duration";
+ Js.Console.timeStart(watchDurationLabel);
+
+ let config =
+ makeConfig(
+ ~mode=Watch,
+ ~outputDir,
+ ~projectRootDir,
+ ~globalEnvValues,
+ ~entries,
+ ~logLevel,
+ ~logOverride,
+ ~logLimit,
+ ~customConfig,
+ ~demoHtmlTemplatePath,
+ );
+
+ let contextPromise = esbuild->context(config);
+
+ GracefulShutdown.addTask(() => {
+ Js.log("[Esbuild] Stopping esbuild...");
+
+ Js.Global.setTimeout(
+ ~f=
+ () => {
+ Js.log("[Esbuild] Failed to gracefully shutdown.");
+ Process.exit(1);
+ },
+ GracefulShutdown.gracefulShutdownTimeout,
+ )
+ ->ignore;
+
+ contextPromise
+ ->Promise.flatMap(context => context->dispose())
+ ->Promise.map(() => Js.log("[Esbuild] Stopped successfully"));
+ });
+
+ contextPromise
+ ->Promise.flatMap(context => context->watch())
+ ->Promise.map(() => Js.Console.timeEnd(watchDurationLabel))
+ ->Promise.catch(error => {
+ Js.Console.error2("[Esbuild] Failed to start watch mode:", error);
+ Process.exit(1);
+ })
+ ->Promise.flatMap(() => {
+ Js.Console.timeStart(serveDurationLabel);
+ contextPromise->Promise.flatMap(context =>
+ context->serve({
+ port,
+ servedir: Some(config##outdir),
+ })
+ );
+ })
+ ->Promise.map(serveResult => {
+ Js.Console.timeEnd(serveDurationLabel);
+ serveResult;
+ })
+ ->Promise.catch(error => {
+ Js.Console.error2("[Esbuild] Failed to start serve mode:", error);
+ Process.exit(1);
+ });
+};
diff --git a/src/new/Fs.re b/src/new/Fs.re
new file mode 100644
index 0000000..aa9e578
--- /dev/null
+++ b/src/new/Fs.re
@@ -0,0 +1,39 @@
+[@mel.module "node:fs"]
+external readFileSync': (~path: string, ~encoding: string) => string =
+ "readFileSync";
+
+[@mel.module "node:fs"]
+external readFileSyncAsBuffer: string => Buffer.t = "readFileSync";
+
+[@mel.module "node:fs"]
+external writeFileSync: (~path: string, ~data: string) => unit =
+ "writeFileSync";
+
+[@mel.module "node:fs"] external existsSync: string => bool = "existsSync";
+
+type mkDirOptions = {recursive: bool};
+
+[@mel.module "node:fs"]
+external mkDirSync: (string, mkDirOptions) => unit = "mkdirSync";
+
+type rmSyncOptions = {
+ force: bool,
+ recursive: bool,
+};
+
+[@mel.module "node:fs"]
+external rmSync: (string, rmSyncOptions) => unit = "rmSync";
+
+let readFileSyncAsUtf8 = path => readFileSync'(~path, ~encoding="utf8");
+
+module Promises = {
+ [@mel.module "node:fs/promises"]
+ external readFileAsBuffer: string => Promise.t(Buffer.t) = "readFile";
+
+ [@mel.module "node:fs/promises"]
+ external mkDir: (string, mkDirOptions) => Promise.t(unit) = "mkdir";
+
+ [@mel.module "node:fs/promises"]
+ external writeFile: (~path: string, ~data: string) => Promise.t(unit) =
+ "writeFile";
+};
diff --git a/src/new/GracefulShutdown.re b/src/new/GracefulShutdown.re
new file mode 100644
index 0000000..9c6e212
--- /dev/null
+++ b/src/new/GracefulShutdown.re
@@ -0,0 +1,29 @@
+let gracefulShutdownTimeout = 3000;
+
+type shutdownRunningTask = unit => Js.Promise.t(unit);
+
+let runningTasks: ref(array(shutdownRunningTask)) = ref([||]);
+
+let addTask = (task: shutdownRunningTask) => {
+ runningTasks := Js.Array.concat(~other=runningTasks^, [|task|]);
+};
+
+let shutdownRunningTasks = () =>
+ (runningTasks^)->Js.Array.map(~f=terminate => terminate(), _)->Promise.all;
+
+Process.onTerminate(() => {
+ Js.log("[rescript-ssg] Performing graceful shutdown...");
+
+ shutdownRunningTasks()
+ ->Promise.map(_ => {
+ Js.log(
+ "[rescript-ssg] Bye-bye! Graceful shutdown performed successfully",
+ );
+ Process.exit(0);
+ })
+ ->Promise.catch(error => {
+ Js.Console.error2("[rescript-ssg] Graceful shutdown error:", error);
+ Process.exit(1);
+ })
+ ->ignore;
+});
diff --git a/src/new/NewDemoListSidebar.re b/src/new/NewDemoListSidebar.re
new file mode 100644
index 0000000..6df5f01
--- /dev/null
+++ b/src/new/NewDemoListSidebar.re
@@ -0,0 +1,369 @@
+open Belt;
+open Prelude;
+open Layout;
+module URLSearchParams = Bindings.URLSearchParams;
+module Window = Bindings.Window;
+module LocalStorage = Bindings.LocalStorage;
+
+module SidebarLink = {
+ module Css = {
+ open StyleVars;
+
+ let link = [%cx
+ {|
+ text-decoration: none;
+ color: $(Color.blue);
+ display: block;
+ padding: $(Gap.xs) $(Gap.md);
+ border-radius: $(BorderRadius.default);
+ font-size: $(FontSize.md);
+ font-weight: 500;
+ |}
+ ];
+
+ let linkActive = [%cx {|
+ background-color: $(Color.midGray);
+ |}];
+ };
+
+ [@react.component]
+ let make = (~activeDomRef=?, ~href, ~text: React.element) => {
+ let url = ReasonReactRouter.useUrl();
+ let currentPath = "/" ++ String.concat("/", url.path);
+ let isActive = currentPath == href;
+
+ Cn.ifTrue(isActive)}
+ onClick={event =>
+ switch (
+ React.Event.Mouse.metaKey(event),
+ React.Event.Mouse.ctrlKey(event),
+ ) {
+ | (false, false) =>
+ React.Event.Mouse.preventDefault(event);
+ ReasonReactRouter.push(href);
+ | _ => ()
+ }
+ }>
+ text
+ ;
+ };
+};
+
+module Css = {
+ open StyleVars;
+
+ let categoryName = [%cx
+ {|
+ padding: $(Gap.xs) $(Gap.xxs);
+ font-size: $(FontSize.md);
+ font-weight: 500;
+ |}
+ ];
+
+ let sidebarPanelWrapper = [%cx
+ {|
+ position: sticky;
+ top: 0;
+ background-color: $(Color.lightGray);
+ |}
+ ];
+
+ let sidebarPanel = [%cx
+ {|
+ display: flex;
+ align-items: center;
+ gap: $(Gap.xs);
+ |}
+ ];
+
+ let collapseButton = [%cx
+ {|
+ height: 32px;
+ min-width: 32px;
+ width: 32px;
+ cursor: pointer;
+ font-size: $(FontSize.sm);
+ background-color: $(Color.white);
+ color: $(Color.darkGray);
+ border: 1px solid $(Color.midGray);
+ border-radius: $(BorderRadius.default);
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+};
+
+module SearchInput = {
+ module Css = {
+ open StyleVars;
+
+ let inputWrapper = [%cx
+ {|
+ position: relative;
+ display: flex;
+ align-items: center;
+ background-color: $(Color.midGray);
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let input = [%cx
+ {|
+ padding: $(Gap.xs) $(Gap.md);
+ width: 100%;
+ margin: 0;
+ height: 32px;
+ box-sizing: border-box;
+ font-family: inherit;
+ font-size: $(FontSize.md);
+ border: none;
+ background-color: transparent;
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let clearButton = [%cx
+ {|
+ position: absolute;
+ right: 7px;
+ display: flex;
+ cursor: pointer;
+ border: none;
+ padding: 0;
+ margin: 0;
+ background-color: transparent;
+ top: 50%;
+ transform: translateY(-50%);
+ |}
+ ];
+ };
+
+ [@react.component]
+ let make = (~autoFocus=?, ~value, ~onChange, ~onClear) =>
+
+
+ {value == ""
+ ? React.null
+ : }
+
;
+};
+
+let rec isNestedEntityMatchSearch =
+ (demos: array(NewEntity.item), searchString) =>
+ demos->Array.some((item: NewEntity.item) => {
+ switch (item) {
+ | Demo({name: demoName, modulePath: _}) =>
+ let isEntityNameMatchSearch =
+ HighlightTerms.getMatchingTerms(~searchString, ~entityName=demoName)
+ ->Array.size
+ > 0;
+
+ isEntityNameMatchSearch;
+ | Category({name: categoryName, items}) =>
+ let isEntityNameMatchSearch =
+ HighlightTerms.getMatchingTerms(
+ ~searchString,
+ ~entityName=categoryName,
+ )
+ ->Array.size
+ > 0;
+
+ isEntityNameMatchSearch
+ || isNestedEntityMatchSearch(items, searchString);
+ }
+ });
+
+let renderMenu =
+ (
+ ~isCategoriesCollapsedByDefault: bool,
+ ~searchString,
+ ~url: ReasonReactRouter.url,
+ ~publicPath: string,
+ items: array(NewEntity.item),
+ ) => {
+ let activeElementRef = UseScrollIntoView.use();
+
+ let rec renderMenu =
+ (
+ ~parentCategoryMatchedSearch: bool,
+ ~nestingLevel,
+ ~categoryPath: list(string),
+ items: array(NewEntity.item),
+ ) => {
+ items
+ ->Array.map((item: NewEntity.item) => {
+ switch (item) {
+ | NewEntity.Demo({name: demoName, modulePath: _}) =>
+ let searchMatchingTerms =
+ HighlightTerms.getMatchingTerms(
+ ~searchString,
+ ~entityName=demoName,
+ );
+
+ let isEntityNameMatchSearch =
+ searchString == "" || searchMatchingTerms->Belt.Array.size > 0;
+
+ if (isEntityNameMatchSearch || parentCategoryMatchedSearch) {
+ let publicPathSegments =
+ publicPath
+ ->Js.String.split(~sep="/", _)
+ ->Belt.Array.keep(segment => segment != "");
+
+ let demoPathSegments =
+ Belt.List.concat(categoryPath, [demoName])
+ ->Belt.List.map(Utils.slugify)
+ ->Belt.List.toArray;
+
+ let fullPathSegments =
+ Belt.Array.concat(publicPathSegments, demoPathSegments);
+
+ let href = "/" ++ Js.Array.join(~sep="/", fullPathSegments);
+
+ }
+ />;
+ } else {
+ React.null;
+ };
+ | Category({name: categoryName, items}) =>
+ let searchMatchingTerms =
+ HighlightTerms.getMatchingTerms(
+ ~searchString,
+ ~entityName=categoryName,
+ );
+
+ let isEntityNameMatchSearch =
+ searchString == "" || searchMatchingTerms->Belt.Array.size > 0;
+
+ if ((
+ isEntityNameMatchSearch
+ || isNestedEntityMatchSearch(items, searchString)
+ )
+ || parentCategoryMatchedSearch) {
+ let currentPath = Belt.List.concat(categoryPath, [categoryName]);
+ let currentPathString =
+ currentPath
+ ->Belt.List.map(Utils.slugify)
+ ->Belt.List.toArray
+ ->Js.Array.join(~sep="/", _);
+
+ let isCategoryInCurrentPath = {
+ let urlPath = "/" ++ String.concat("/", url.path);
+ Js.String.startsWith(~prefix="/" ++ currentPathString, urlPath);
+ };
+
+
+
+
+
+ }
+ isDefaultOpen={
+ isCategoryInCurrentPath || !isCategoriesCollapsedByDefault
+ }
+ isForceOpen={searchString != ""}>
+
+ {renderMenu(
+ ~parentCategoryMatchedSearch=
+ isEntityNameMatchSearch || parentCategoryMatchedSearch,
+ ~nestingLevel=nestingLevel + 1,
+ ~categoryPath=currentPath,
+ items,
+ )}
+
+
+ ;
+ } else {
+ React.null;
+ };
+ }
+ })
+ ->React.array;
+ };
+
+ renderMenu(
+ ~parentCategoryMatchedSearch=false,
+ ~nestingLevel=0,
+ ~categoryPath=[],
+ items,
+ );
+};
+
+[@react.component]
+let make =
+ (
+ ~items: array(NewEntity.item),
+ ~url: ReasonReactRouter.url,
+ ~isCategoriesCollapsedByDefault: bool,
+ ~onToggleCollapsedCategoriesByDefault: unit => unit,
+ ~publicPath: string,
+ ) => {
+ let (filterValue, setFilterValue) = React.useState(() => None);
+
+
+
+
+
+ Option.getWithDefault("")}
+ onChange={event => {
+ let value = event->React.Event.Form.target##value;
+ setFilterValue(_ =>
+ if (value->Js.String.trim == "") {
+ None;
+ } else {
+ Some(value);
+ }
+ );
+ }}
+ onClear={() => setFilterValue(_ => None)}
+ />
+
+
+
+
+ {renderMenu(
+ ~isCategoriesCollapsedByDefault,
+ ~searchString=
+ filterValue->Option.mapWithDefault("", Js.String.toLowerCase),
+ ~url,
+ ~publicPath,
+ items,
+ )}
+
+ ;
+};
diff --git a/src/new/NewEntity.re b/src/new/NewEntity.re
new file mode 100644
index 0000000..0059cbc
--- /dev/null
+++ b/src/new/NewEntity.re
@@ -0,0 +1,19 @@
+open Melange_json.Primitives;
+
+[@deriving (json, json_string)]
+type demo = {
+ name: string,
+ modulePath: string,
+};
+
+[@deriving (json, json_string)]
+type category = {
+ name: string,
+ items: array(item),
+}
+and item =
+ | Demo(demo)
+ | Category(category);
+
+[@deriving (json, json_string)]
+type items = array(item);
diff --git a/src/new/NewEntry.re b/src/new/NewEntry.re
new file mode 100644
index 0000000..1ebcec5
--- /dev/null
+++ b/src/new/NewEntry.re
@@ -0,0 +1,258 @@
+let makeDemoTemplate = (~filepath: string) => {j|
+import * as Demo from "$(filepath)";
+import * as Client from "react-dom/client";
+import * as JsxRuntime from "react/jsx-runtime";
+
+const root = document.querySelector("#root");
+
+if (!(root == null)) {
+ const root1 = Client.createRoot(root);
+ root1.render(JsxRuntime.jsx(Demo.make, {}));
+}
+|j};
+
+let makeMainTemplate =
+ (~filepath: string, ~items: array(NewEntity.item), ~publicPath: string) => {
+ // We also call JSON.stringify below because the data interpolated to js file as a normal js object
+ let itemsJsonString = items->NewEntity.items_to_json_string;
+ {j|
+import * as Demo from "$(filepath)";
+import * as Client from "react-dom/client";
+import * as JsxRuntime from "react/jsx-runtime";
+
+const root = document.querySelector("#root");
+
+const publicPath = "$(publicPath)";
+
+const itemsJsonString = JSON.stringify($(itemsJsonString));
+
+if (!(root == null)) {
+ const root1 = Client.createRoot(root);
+ root1.render(JsxRuntime.jsx(Demo.make,
+ {
+ itemsJsonString: itemsJsonString,
+ publicPath: publicPath
+ }));
+}
+|j};
+};
+
+let htmlTemplate = {js|
+
+
+
+
+
+
+|js};
+
+type extractedDemo = {
+ // original path to the compiled demo module
+ filepath: string,
+ // path segments according to the structure defined by user (category names + demo name as the last segment)
+ targetPath: list(string),
+};
+
+let targetPathToPath = targetPath => {
+ targetPath
+ ->List.rev
+ ->Belt.List.map(Utils.slugify)
+ ->Belt.List.toArray
+ ->Js.Array.join(~sep="/", _);
+};
+
+let demoTargetPathToJsEntryPath = targetPath => {
+ let path = targetPathToPath(targetPath);
+ Path.join2(path, "demo.js");
+};
+
+let extractDemos = (~items: array(NewEntity.item)): list(extractedDemo) => {
+ let rec extractWithPath =
+ (~path: list(string), ~items: array(NewEntity.item))
+ : list(extractedDemo) => {
+ Js.Array.reduce(
+ ~f=
+ (acc, item) => {
+ switch (item) {
+ | NewEntity.Demo(demo) =>
+ let targetPath = [demo.name, ...path];
+ let extracted = {
+ filepath: demo.modulePath,
+ targetPath,
+ };
+ [extracted, ...acc];
+ | NewEntity.Category(category) =>
+ let nestedDemos =
+ extractWithPath(
+ ~path=[category.name, ...path],
+ ~items=category.items,
+ );
+ List.append(nestedDemos, acc);
+ }
+ },
+ ~init=[],
+ items,
+ );
+ };
+
+ extractWithPath(~path=[], ~items);
+};
+
+let envOutputDir = Process.env->Js.Dict.get("OUTPUT_DIR");
+
+let envDemoHtmlTemplatePath =
+ Process.env->Js.Dict.get("DEMO_HTML_TEMPLATE_PATH");
+
+let customConfigPath = Process.env->Js.Dict.get("CUSTOM_CONFIG_PATH");
+
+let envPort =
+ Process.env->Js.Dict.get("PORT")->Belt.Option.flatMap(int_of_string_opt);
+
+let mode =
+ Process.env->Js.Dict.get("MODE")->Belt.Option.getWithDefault("build");
+
+let mode =
+ switch (mode) {
+ | "build" => Bundler.Build
+ | "watch" => Watch
+ | _ => Build
+ };
+
+let start =
+ (
+ ~outputDir: string,
+ ~port: option(int)=?,
+ ~items: array(NewEntity.item),
+ ~demoHtmlTemplatePath: option(string)=?,
+ (),
+ ) => {
+ let customConfigPromise =
+ switch (customConfigPath) {
+ | None => Promise.resolve(None)
+ | Some(path) =>
+ Esbuild.CustomConfig.readCustomConfig(~customConfigPath=path)
+ };
+
+ customConfigPromise
+ ->Promise.map(customConfig => {
+ let outputDir = envOutputDir->Belt.Option.getWithDefault(outputDir);
+ let demos = extractDemos(~items);
+ // TODO double check this
+ let esbuildOutputDir = outputDir;
+
+ let mainEntryModulePath = NewReshowcaseUi2.modulePath;
+ let mainEntryJsPath = Path.join2(outputDir, "main.js");
+ let mainEntryTemplate =
+ makeMainTemplate(
+ ~filepath=mainEntryModulePath,
+ ~items,
+ ~publicPath=
+ customConfig
+ ->Belt.Option.flatMap(config => config.publicPath)
+ ->Belt.Option.getWithDefault("/"),
+ );
+
+ let mainEntry: Esbuild.Entry.t = {
+ path: "/",
+ entryPath: mainEntryJsPath,
+ };
+
+ let () = Fs.mkDirSync(outputDir, {recursive: true});
+ let () =
+ Fs.writeFileSync(~path=mainEntryJsPath, ~data=mainEntryTemplate);
+
+ let demosEntries = {
+ demos
+ ->Belt.List.map(extractedDemo => {
+ let demoEntryJsPath =
+ Path.join2(
+ outputDir,
+ demoTargetPathToJsEntryPath(extractedDemo.targetPath),
+ );
+
+ let template = makeDemoTemplate(~filepath=extractedDemo.filepath);
+ let () =
+ Fs.mkDirSync(
+ Path.dirname(demoEntryJsPath),
+ {recursive: true},
+ );
+ let () = Fs.writeFileSync(~path=demoEntryJsPath, ~data=template);
+
+ let demoPath = extractedDemo.targetPath->targetPathToPath;
+
+ // Generate index.html (main app) for this demo path
+ let mainAppRenderedPage: Esbuild.Entry.t = {
+ path: demoPath,
+ entryPath: mainEntryJsPath,
+ };
+
+ // Generate iframe.html (demo only) for this demo path
+ let iframeRenderedPage: Esbuild.Entry.t = {
+ path: Path.join2(demoPath, "iframe"),
+ entryPath: demoEntryJsPath,
+ };
+
+ [mainAppRenderedPage, iframeRenderedPage];
+ })
+ ->Belt.List.flatten;
+ };
+
+ let entries =
+ Belt.Array.concat([|mainEntry|], demosEntries->Array.of_list);
+
+ let () = {
+ let outputDir = esbuildOutputDir;
+ let projectRootDir = "";
+ let globalEnvValues = [||];
+ let entries = entries;
+ let logLevel = Esbuild.LogLevel.Debug;
+ let port =
+ switch (envPort) {
+ | Some(port) => port
+ | None =>
+ switch (port) {
+ | Some(port) => port
+ | None => 8000
+ }
+ };
+
+ let demoHtmlTemplatePath =
+ switch (envDemoHtmlTemplatePath) {
+ | Some(path) => Some(path)
+ | None => demoHtmlTemplatePath
+ };
+
+ switch (mode) {
+ | Build =>
+ let _promise: Js.promise(unit) =
+ Esbuild.build(
+ ~outputDir,
+ ~projectRootDir,
+ ~customConfig,
+ ~globalEnvValues,
+ ~entries,
+ ~logLevel,
+ ~demoHtmlTemplatePath?,
+ (),
+ );
+ ();
+ | Watch =>
+ let _promise: Js.promise(Esbuild.serveResult) =
+ Esbuild.watchAndServe(
+ ~outputDir,
+ ~projectRootDir,
+ ~customConfig,
+ ~globalEnvValues,
+ ~entries,
+ ~logLevel,
+ ~port,
+ ~demoHtmlTemplatePath?,
+ (),
+ );
+ ();
+ };
+ };
+ ();
+ })
+ ->ignore;
+};
diff --git a/src/new/NewExample.re b/src/new/NewExample.re
new file mode 100644
index 0000000..89ef7fb
--- /dev/null
+++ b/src/new/NewExample.re
@@ -0,0 +1,4 @@
+[@react.component]
+let make = () => {
+ {React.string("Hello, world!")}
;
+};
diff --git a/src/new/NewReshowcaseUi.re b/src/new/NewReshowcaseUi.re
new file mode 100644
index 0000000..6034b6b
--- /dev/null
+++ b/src/new/NewReshowcaseUi.re
@@ -0,0 +1,715 @@
+open Belt;
+open Prelude;
+open Layout;
+module URLSearchParams = Bindings.URLSearchParams;
+module Window = Bindings.Window;
+module LocalStorage = Bindings.LocalStorage;
+
+type responsiveMode =
+ | Mobile
+ | Desktop;
+
+module TopPanel = {
+ module Css = {
+ open StyleVars;
+
+ let panel = [%cx
+ {|
+ display: flex;
+ justify-content: flex-end;
+ border-bottom: 1px solid $(Color.midGray);
+ |}
+ ];
+
+ let buttonGroup = [%cx
+ {|
+ overflow: hidden;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let button = [%cx
+ {|
+ height: 32px;
+ width: 48px;
+ cursor: pointer;
+ font-size: $(FontSize.sm);
+ background-color: $(Color.lightGray);
+ color: $(Color.darkGray);
+ border: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+
+ let buttonSquare = [%cx {|
+ width: 32px;
+ |}];
+
+ let buttonActive = [%cx
+ {|
+ background-color: $(Color.blue);
+ color: $(Color.white);
+ |}
+ ];
+
+ let middleSection = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ |}
+ ];
+
+ let rightSection = [%cx {|
+ display: flex;
+ |}];
+ };
+
+ [@react.component]
+ let make =
+ (
+ ~responsiveMode: responsiveMode,
+ ~onSetResponsiveMode: (responsiveMode => responsiveMode) => unit,
+ ) =>
+
+
+
+
+
+
+
+
+
+
+
+
;
+};
+
+module SidebarLink = {
+ module Css = {
+ open StyleVars;
+
+ let link = [%cx
+ {|
+ text-decoration: none;
+ color: $(Color.blue);
+ display: block;
+ padding: $(Gap.xs) $(Gap.md);
+ border-radius: $(BorderRadius.default);
+ font-size: $(FontSize.md);
+ font-weight: 500;
+ |}
+ ];
+
+ let linkActive = [%cx {|
+ background-color: $(Color.midGray);
+ |}];
+ };
+
+ [@react.component]
+ let make = (~activeDomRef=?, ~href, ~text: React.element) => {
+ let url = ReasonReactRouter.useUrl();
+ let path = String.concat("/", url.path);
+ let isActive =
+ Js.String.endsWith(~suffix=href, path ++ "?" ++ url.search);
+
+ Cn.ifTrue(isActive)}
+ onClick={event =>
+ switch (
+ React.Event.Mouse.metaKey(event),
+ React.Event.Mouse.ctrlKey(event),
+ ) {
+ | (false, false) =>
+ React.Event.Mouse.preventDefault(event);
+ ReasonReactRouter.push(href);
+ | _ => ()
+ }
+ }>
+ text
+ ;
+ };
+};
+
+module DemoListSidebar = {
+ module Css = {
+ open StyleVars;
+
+ let categoryName = [%cx
+ {|
+ padding: $(Gap.xs) $(Gap.xxs);
+ font-size: $(FontSize.md);
+ font-weight: 500;
+ |}
+ ];
+
+ let sidebarPanelWrapper = [%cx
+ {|
+ position: sticky;
+ top: 0;
+ background-color: $(Color.lightGray);
+ |}
+ ];
+
+ let sidebarPanel = [%cx
+ {|
+ display: flex;
+ align-items: center;
+ gap: $(Gap.xs);
+ |}
+ ];
+
+ let collapseButton = [%cx
+ {|
+ height: 32px;
+ min-width: 32px;
+ width: 32px;
+ cursor: pointer;
+ font-size: $(FontSize.sm);
+ background-color: $(Color.white);
+ color: $(Color.darkGray);
+ border: 1px solid $(Color.midGray);
+ border-radius: $(BorderRadius.default);
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+ };
+
+ module SearchInput = {
+ module Css = {
+ open StyleVars;
+
+ let inputWrapper = [%cx
+ {|
+ position: relative;
+ display: flex;
+ align-items: center;
+ background-color: $(Color.midGray);
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let input = [%cx
+ {|
+ padding: $(Gap.xs) $(Gap.md);
+ width: 100%;
+ margin: 0;
+ height: 32px;
+ box-sizing: border-box;
+ font-family: inherit;
+ font-size: $(FontSize.md);
+ border: none;
+ background-color: transparent;
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let clearButton = [%cx
+ {|
+ position: absolute;
+ right: 7px;
+ display: flex;
+ cursor: pointer;
+ border: none;
+ padding: 0;
+ margin: 0;
+ background-color: transparent;
+ top: 50%;
+ transform: translateY(-50%);
+ |}
+ ];
+ };
+
+ [@react.component]
+ let make = (~autoFocus=?, ~value, ~onChange, ~onClear) =>
+
+
+ {value == ""
+ ? React.null
+ : }
+
;
+ };
+
+ let renderMenu =
+ (
+ ~isCategoriesCollapsedByDefault: bool,
+ ~urlSearchParams: URLSearchParams.t,
+ ~searchString,
+ demos: Demos.t,
+ ) => {
+ let activeElementRef = UseScrollIntoView.use();
+
+ let rec renderMenu =
+ (
+ ~parentCategoryMatchedSearch: bool,
+ ~nestingLevel,
+ ~categoryQuery,
+ demos: Demos.t,
+ ) => {
+ let demos = demos->Js.Dict.entries;
+ demos
+ ->Array.map(((entityName, entity)) => {
+ let searchMatchingTerms =
+ HighlightTerms.getMatchingTerms(~searchString, ~entityName);
+
+ let isEntityNameMatchSearch =
+ searchString == "" || searchMatchingTerms->Belt.Array.size > 0;
+
+ switch (entity) {
+ | Entity.Demo(_) =>
+ if (isEntityNameMatchSearch || parentCategoryMatchedSearch) {
+ Js.Global.encodeURIComponent)
+ ++ categoryQuery
+ }
+ text={
+
+ }
+ />;
+ } else {
+ React.null;
+ }
+ | Category(demos) =>
+ if ((
+ isEntityNameMatchSearch
+ || Demos.isNestedEntityMatchSearch(demos, searchString)
+ )
+ || parentCategoryMatchedSearch) {
+ let levelStr = Int.toString(nestingLevel);
+ let categoryQueryKey = {js|category|js} ++ levelStr;
+ let isCategoryInQuery =
+ switch (
+ urlSearchParams->URLSearchParams.get(categoryQueryKey)
+ ) {
+ | Some(value)
+ when value->Js.Global.decodeURIComponent == entityName =>
+ true
+ | Some(_)
+ | None => false
+ };
+
+
+
+
+
+ }
+ isDefaultOpen={
+ isCategoryInQuery || !isCategoriesCollapsedByDefault
+ }
+ isForceOpen={searchString != ""}>
+
+ {renderMenu(
+ ~parentCategoryMatchedSearch=
+ isEntityNameMatchSearch || parentCategoryMatchedSearch,
+ ~nestingLevel=nestingLevel + 1,
+ ~categoryQuery=
+ (
+ (({js|&category|js} ++ levelStr) ++ {js|=|js})
+ ++ entityName->Js.Global.encodeURIComponent
+ )
+ ++ categoryQuery,
+ demos,
+ )}
+
+
+ ;
+ } else {
+ React.null;
+ }
+ };
+ })
+ ->React.array;
+ };
+
+ renderMenu(
+ ~parentCategoryMatchedSearch=false,
+ ~nestingLevel=0,
+ ~categoryQuery="",
+ demos: Demos.t,
+ );
+ };
+
+ [@react.component]
+ let make =
+ (
+ ~urlSearchParams: URLSearchParams.t,
+ ~demos: Demos.t,
+ ~isCategoriesCollapsedByDefault: bool,
+ ~onToggleCollapsedCategoriesByDefault: unit => unit,
+ ) => {
+ let (filterValue, setFilterValue) = React.useState(() => None);
+
+
+
+
+
+ Option.getWithDefault("")}
+ onChange={event => {
+ let value = event->React.Event.Form.target##value;
+
+ setFilterValue(_ =>
+ if (value->Js.String.trim == "") {
+ None;
+ } else {
+ Some(value);
+ }
+ );
+ }}
+ onClear={() => setFilterValue(_ => None)}
+ />
+
+
+
+
+ {renderMenu(
+ ~isCategoriesCollapsedByDefault,
+ ~searchString=
+ filterValue->Option.mapWithDefault("", Js.String.toLowerCase),
+ ~urlSearchParams,
+ demos,
+ )}
+
+ ;
+ };
+};
+
+module DemoUnit = {
+ module Css = {
+ let container = [%cx
+ {|
+ flex-grow: 1;
+ display: flex;
+ align-items: stretch;
+ flex-direction: row;
+ |}
+ ];
+
+ let contents = [%cx
+ {|
+ flex-grow: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ -webkit-overflow-scrolling: touch;
+ |}
+ ];
+ };
+
+ [@react.component]
+ let make = (~demoUnit: Configs.demoUnitProps => React.element) => {
+ let props: Configs.demoUnitProps = {
+ string: (_name, ~options as _=?, config) => config,
+ int: (_name, config) => config.initial,
+ float: (_name, config) => config.initial,
+ bool: (_name, config) => config,
+ };
+
+ ;
+ };
+};
+
+module DemoUnitFrame = {
+ module Css = {
+ open StyleVars;
+
+ let container = [%cx
+ {|
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 1px;
+ overflow-y: auto;
+ |}
+ ];
+
+ let containerBackground = responsiveMode => {
+ let backgroundColor =
+ switch (responsiveMode) {
+ | Mobile => Color.midGray
+ | Desktop => Color.white
+ };
+ [%cx {|
+ background-color: $(backgroundColor);
+ |}];
+ };
+
+ let iframe = responsiveMode => {
+ let height =
+ switch (responsiveMode) {
+ | Mobile => `px(667)
+ | Desktop => `percent(100.)
+ };
+ let width =
+ switch (responsiveMode) {
+ | Mobile => `px(375)
+ | Desktop => `percent(100.)
+ };
+ [%cx
+ {|
+ border: none;
+ height: $(height);
+ width: $(width);
+ |}
+ ];
+ };
+ };
+
+ let useFullframeUrl: bool = [%mel.raw
+ {js|typeof USE_FULL_IFRAME_URL === "boolean" ? USE_FULL_IFRAME_URL : false|js}
+ ];
+
+ [@react.component]
+ let make =
+ (~queryString: string, ~responsiveMode, ~onLoad: Js.t('a) => unit) => {
+ let iframePath = if (useFullframeUrl) {"demo/index.html"} else {"demo"};
+
+
;
+ };
+};
+
+module App = {
+ module Css = {
+ open StyleVars;
+
+ let app = [%cx
+ {|
+ display: flex;
+ flex-direction: row;
+ min-height: 100vh;
+ align-items: stretch;
+ color: $(Color.darkGray);
+ |}
+ ];
+
+ let main = [%cx
+ {|
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ |}
+ ];
+
+ let empty = [%cx
+ {|
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+
+ let emptyText = [%cx
+ {|
+ font-size: $(FontSize.lg);
+ color: $(Color.black40a);
+ text-align: center;
+ |}
+ ];
+
+ let right = [%cx
+ {|
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ |}
+ ];
+
+ let demo = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ align-items: stretch;
+ |}
+ ];
+
+ let demoContents = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ |}
+ ];
+ };
+
+ type route =
+ | Unit(URLSearchParams.t, string)
+ | Demo(string)
+ | Home;
+
+ [@react.component]
+ let make = (~demos: Demos.t) => {
+ let url = ReasonReactRouter.useUrl();
+ let urlSearchParams = url.search->URLSearchParams.make;
+ let route =
+ switch (
+ urlSearchParams->URLSearchParams.get("iframe"),
+ urlSearchParams->URLSearchParams.get("demo"),
+ ) {
+ | (Some("true"), Some(demoName)) => Unit(urlSearchParams, demoName)
+ | (_, Some(_)) => Demo(url.search)
+ | _ => Home
+ };
+
+ let (iframeKey, setIframeKey) =
+ React.useState(() => Js.Date.now()->Float.toString);
+
+ React.useEffect1(
+ () => {
+ setIframeKey(_ => Js.Date.now()->Float.toString);
+ None;
+ },
+ [|url|],
+ );
+
+ let (responsiveMode, onSetResponsiveMode) = React.useState(() => Desktop);
+ let (isCategoriesCollapsedByDefault, toggleIsCategoriesCollapsed) =
+ React.useState(() =>
+ switch (
+ LocalStorage.localStorage->LocalStorage.getItem(
+ "isCategoriesCollapsedByDefault",
+ )
+ ) {
+ | Some("true") => true
+ | _ => false
+ }
+ );
+
+ let onToggleCollapsedCategoriesByDefault = () => {
+ toggleIsCategoriesCollapsed(_ => !isCategoriesCollapsedByDefault);
+ LocalStorage.localStorage->LocalStorage.setItem(
+ "isCategoriesCollapsedByDefault",
+ isCategoriesCollapsedByDefault ? "false" : "true",
+ );
+ };
+
+
+ {switch (route) {
+ | Unit(urlSearchParams, demoName) =>
+ let demoUnit = Demos.findDemo(urlSearchParams, demoName, demos);
+
+ {demoUnit
+ ->Option.map(demoUnit => )
+ ->Option.getWithDefault("Demo not found"->React.string)}
+
;
+ | Demo(queryString) =>
+ <>
+
+
+ >
+
+ | Home =>
+ <>
+
+
+
"Pick a demo"->React.string
+
+ >
+ }}
+
;
+ };
+};
diff --git a/src/new/NewReshowcaseUi2.re b/src/new/NewReshowcaseUi2.re
new file mode 100644
index 0000000..0787514
--- /dev/null
+++ b/src/new/NewReshowcaseUi2.re
@@ -0,0 +1,342 @@
+// open Belt;
+open Prelude;
+open Layout;
+module URLSearchParams = Bindings.URLSearchParams;
+module Window = Bindings.Window;
+module LocalStorage = Bindings.LocalStorage;
+
+type responsiveMode =
+ | Mobile
+ | Desktop;
+
+module TopPanel = {
+ module Css = {
+ open StyleVars;
+
+ let panel = [%cx
+ {|
+ display: flex;
+ justify-content: flex-end;
+ border-bottom: 1px solid $(Color.midGray);
+ |}
+ ];
+
+ let buttonGroup = [%cx
+ {|
+ overflow: hidden;
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ border-radius: $(BorderRadius.default);
+ |}
+ ];
+
+ let button = [%cx
+ {|
+ height: 32px;
+ width: 48px;
+ cursor: pointer;
+ font-size: $(FontSize.sm);
+ background-color: $(Color.lightGray);
+ color: $(Color.darkGray);
+ border: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+
+ let buttonSquare = [%cx {|
+ width: 32px;
+ |}];
+
+ let buttonActive = [%cx
+ {|
+ background-color: $(Color.blue);
+ color: $(Color.white);
+ |}
+ ];
+
+ let middleSection = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ justify-content: center;
+ |}
+ ];
+
+ let rightSection = [%cx {|
+ display: flex;
+ |}];
+ };
+
+ [@react.component]
+ let make =
+ (
+ ~responsiveMode: responsiveMode,
+ ~onSetResponsiveMode: (responsiveMode => responsiveMode) => unit,
+ ) =>
+
+
+
+
+
+
+
+
+
+
+
+
;
+};
+
+module DemoUnitFrame = {
+ module Css = {
+ open StyleVars;
+
+ let container = [%cx
+ {|
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 1px;
+ overflow-y: auto;
+ |}
+ ];
+
+ let containerBackground = responsiveMode => {
+ let backgroundColor =
+ switch (responsiveMode) {
+ | Mobile => Color.midGray
+ | Desktop => Color.white
+ };
+ [%cx {|
+ background-color: $(backgroundColor);
+ |}];
+ };
+
+ let iframe = responsiveMode => {
+ let height =
+ switch (responsiveMode) {
+ | Mobile => `px(667)
+ | Desktop => `percent(100.)
+ };
+ let width =
+ switch (responsiveMode) {
+ | Mobile => `px(375)
+ | Desktop => `percent(100.)
+ };
+ [%cx
+ {|
+ border: none;
+ height: $(height);
+ width: $(width);
+ |}
+ ];
+ };
+ };
+
+ let useFullframeUrl: bool = [%mel.raw
+ {js|typeof USE_FULL_IFRAME_URL === "boolean" ? USE_FULL_IFRAME_URL : false|js}
+ ];
+
+ [@react.component]
+ let make = (~path: string, ~responsiveMode, ~onLoad: Js.t('a) => unit) => {
+
+
;
+ };
+};
+
+module Css = {
+ open StyleVars;
+
+ let app = [%cx
+ {|
+ display: flex;
+ flex-direction: row;
+ min-height: 100vh;
+ align-items: stretch;
+ color: $(Color.darkGray);
+ |}
+ ];
+
+ let main = [%cx
+ {|
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ |}
+ ];
+
+ let empty = [%cx
+ {|
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ |}
+ ];
+
+ let emptyText = [%cx
+ {|
+ font-size: $(FontSize.lg);
+ color: $(Color.black40a);
+ text-align: center;
+ |}
+ ];
+
+ let right = [%cx
+ {|
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ |}
+ ];
+
+ let demo = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ flex-direction: row;
+ align-items: stretch;
+ |}
+ ];
+
+ let demoContents = [%cx
+ {|
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ |}
+ ];
+};
+
+type route =
+ | Demo(list(string))
+ | Home;
+
+module App = {
+ [@react.component]
+ let make = (~itemsJsonString, ~publicPath: string) => {
+ let items = itemsJsonString->NewEntity.items_of_json_string;
+ let url = ReasonReactRouter.useUrl();
+ let route = {
+ switch (url.path) {
+ | [] => Home
+ | pathParts => Demo(pathParts)
+ };
+ };
+
+ let (isCategoriesCollapsedByDefault, toggleIsCategoriesCollapsed) =
+ React.useState(() =>
+ switch (
+ LocalStorage.localStorage->LocalStorage.getItem(
+ "isCategoriesCollapsedByDefault",
+ )
+ ) {
+ | Some("true") => true
+ | _ => false
+ }
+ );
+
+ let onToggleCollapsedCategoriesByDefault = () => {
+ toggleIsCategoriesCollapsed(_ => !isCategoriesCollapsedByDefault);
+ LocalStorage.localStorage->LocalStorage.setItem(
+ "isCategoriesCollapsedByDefault",
+ isCategoriesCollapsedByDefault ? "false" : "true",
+ );
+ };
+
+ let (responsiveMode, onSetResponsiveMode) = React.useState(() => Desktop);
+
+
+ <>
+
+ {switch (route) {
+ | Home =>
+
+
"Pick a demo"->React.string
+
+ | Demo(pathParts) =>
+ let iframePathSegments =
+ Belt.Array.concat(
+ Belt.List.toArray(pathParts),
+ [|"iframe", "index.html"|],
+ );
+
+ let iframePath =
+ "/" ++ Js.Array.join(~sep="/", iframePathSegments);
+
+
;
+ }}
+ >
+
;
+ };
+};
+
+[@react.component]
+let make = (~itemsJsonString, ~publicPath) =>
+ {
+ Js.log(error);
+ {React.string("Something went wrong")}
;
+ }}>
+
+ ;
+
+let modulePath = Utils.getFilepath();
diff --git a/src/new/NewTemplate.re b/src/new/NewTemplate.re
new file mode 100644
index 0000000..933edce
--- /dev/null
+++ b/src/new/NewTemplate.re
@@ -0,0 +1,6 @@
+switch (ReactDOM.querySelector("#root")) {
+| Some(root) =>
+ let root = ReactDOM.Client.createRoot(root);
+ ReactDOM.Client.render(root, );
+| None => ()
+};
diff --git a/src/new/Path.re b/src/new/Path.re
new file mode 100644
index 0000000..111baf2
--- /dev/null
+++ b/src/new/Path.re
@@ -0,0 +1,8 @@
+[@mel.module "node:path"] external join2: (string, string) => string = "join";
+[@mel.module "node:path"]
+external join3: (string, string, string) => string = "join";
+[@mel.module "node:path"] external basename: string => string = "basename";
+[@mel.module "node:path"] external extname: string => string = "extname";
+[@mel.module "node:path"] external dirname: string => string = "dirname";
+[@mel.module "node:path"]
+external relative: (~from: string, ~to_: string) => string = "relative";
diff --git a/src/new/Performance.re b/src/new/Performance.re
new file mode 100644
index 0000000..66d485b
--- /dev/null
+++ b/src/new/Performance.re
@@ -0,0 +1,5 @@
+[@mel.module "node:perf_hooks"] [@mel.scope "performance"]
+external now: unit => float = "now";
+
+let durationSinceStartTime = (~startTime) =>
+ (now() -. startTime |> Js.Float.toFixed(~digits=2)) ++ " ms";
diff --git a/src/new/Promise.re b/src/new/Promise.re
new file mode 100644
index 0000000..dd9be79
--- /dev/null
+++ b/src/new/Promise.re
@@ -0,0 +1,86 @@
+include Js.Promise;
+
+[@mel.send]
+external map: (Js.Promise.t('a), 'a => 'b) => Js.Promise.t('b) = "then";
+
+[@mel.send]
+external flatMap:
+ (Js.Promise.t('a), 'a => Js.Promise.t('b)) => Js.Promise.t('b) =
+ "then";
+
+[@mel.send]
+external catch:
+ (Js.Promise.t('a), Js.Promise.error => Js.Promise.t('b)) =>
+ Js.Promise.t('b) =
+ "catch";
+
+let seqRun = (functions: array(unit => Js.Promise.t('a))) => {
+ Js.Array.reduce(
+ functions,
+ ~f=
+ (acc, func) => {
+ switch (acc) {
+ | [] => [func()]
+ | [promise, ...rest] => [
+ promise->flatMap(_ => func()),
+ promise,
+ ...rest,
+ ]
+ }
+ },
+ ~init=[],
+ )
+ ->Belt.List.toArray
+ ->Js.Promise.all;
+};
+
+module Result = {
+ let catch =
+ (promise, ~context: string)
+ : Js.Promise.t(Belt.Result.t('ok, (string, Js.Promise.error))) =>
+ promise
+ ->map(value => Belt.Result.Ok(value))
+ ->catch(error => Belt.Result.Error((context, error))->Js.Promise.resolve);
+
+ let all = (promises: Js.Promise.t(array(Belt.Result.t('ok, 'error)))) =>
+ promises->map(promises => {
+ let (oks, errors) =
+ promises->Js.Array.reduce(
+ ~f=
+ ((oks, errors), result) =>
+ switch (result) {
+ | Ok(ok) => (
+ Js.Array.concat(~other=oks, [|ok|]),
+ errors,
+ )
+ | Error(error) => (
+ oks,
+ Js.Array.concat(~other=errors, [|error|]),
+ )
+ },
+ ~init=([||], [||]),
+ _,
+ );
+
+ switch (errors) {
+ | [||] => Ok(oks)
+ | _ => Error(errors)
+ };
+ });
+
+ let map =
+ (promise: Js.Promise.t(Belt.Result.t('a, 'error)), func: 'a => 'b) =>
+ promise->map(result => result->Belt.Result.map(func));
+
+ let flatMap =
+ (
+ promise: Js.Promise.t(Belt.Result.t('a, 'error)),
+ func: 'a => Js.Promise.t('b),
+ ) =>
+ promise->flatMap(result =>
+ switch (result) {
+ | Ok(ok) => func(ok)
+ | Error(error) => Js.Promise.resolve(Error(error))
+ }
+ );
+};
diff --git a/tests/HighlightTermsTest.re b/tests/HighlightTermsTest.re
index 0572843..8f7eb93 100644
--- a/tests/HighlightTermsTest.re
+++ b/tests/HighlightTermsTest.re
@@ -4,7 +4,14 @@ external process: 'a = "process";
[@mel.module] external util: 'a = "util";
let inspect = (value): string =>
- util##inspect(value, {"compact": false, "depth": 20, "colors": true});
+ util##inspect(
+ value,
+ {
+ "compact": false,
+ "depth": 20,
+ "colors": true,
+ },
+ );
let exitWithError = (): unit => process##exit(1);