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) { + + + {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, + }; + +
+
{demoUnit(props)}
+
; + }; +}; + +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"}; +
+