"
6 | depends: [
7 | "dune" {>= "2.7" & < "3"}
8 | "ocaml" {= "4.06.1"}
9 | "ocaml-lsp-server" {= "1.4.1"}
10 | "ocamlformat" {= "0.13.0"}
11 | "reason" {= "3.6.0"}
12 | "merlin" {= "3.4.0"}
13 | ]
14 |
--------------------------------------------------------------------------------
/src/bindings/Set.re:
--------------------------------------------------------------------------------
1 | type t('a);
2 | [@bs.new] external fromArray: array('a) => t('a) = "Set";
3 |
4 | [@send] external has: (t('a), 'a) => bool = "has";
5 |
6 | [@send] external add: (t('a), 'a) => t('a) = "add";
7 |
8 | type arrayModule;
9 |
10 | [@bs.val] external arrayModule: arrayModule = "Array";
11 | [@bs.send] external arrayFrom: (arrayModule, t('a)) => array('a) = "from";
12 |
13 | let toArray = set => arrayModule->arrayFrom(set);
14 |
--------------------------------------------------------------------------------
/.github/workflows/nodejs.yml:
--------------------------------------------------------------------------------
1 | name: rescript-ssg
2 |
3 | on: ["push"]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 |
12 | - name: Setup Node.js 18.14.0
13 | uses: actions/setup-node@v1
14 | with:
15 | node-version: '18.14.0'
16 |
17 | - name: Install dependencies
18 | run: npm ci
19 |
20 | - name: Build and test
21 | run: make build-ci
22 |
--------------------------------------------------------------------------------
/src/bindings/Path.re:
--------------------------------------------------------------------------------
1 | [@bs.module "node:path"] external join2: (string, string) => string = "join";
2 | [@bs.module "node:path"]
3 | external join3: (string, string, string) => string = "join";
4 | [@bs.module "node:path"] external basename: string => string = "basename";
5 | [@bs.module "node:path"] external extname: string => string = "extname";
6 | [@bs.module "node:path"] external dirname: string => string = "dirname";
7 | [@bs.module "node:path"]
8 | external relative: (~from: string, ~to_: string) => string = "relative";
9 |
--------------------------------------------------------------------------------
/example/src/commands/Start.re:
--------------------------------------------------------------------------------
1 | let currentDir = Utils.getDirname();
2 |
3 | let () =
4 | Commands.start(
5 | ~pageAppArtifactsType=Js,
6 | ~pages=Pages.pages,
7 | ~webpackDevServerOptions={listenTo: Port(9007), proxy: None},
8 | ~webpackMode=Development,
9 | ~outputDir=Pages.outputDir,
10 | ~projectRootDir=Path.join2(currentDir, "../../../"),
11 | ~logLevel=Info,
12 | ~globalEnvValues=Pages.globalEnvValues,
13 | ~webpackBundleAnalyzerMode=None,
14 | ~buildWorkersCount=1,
15 | (),
16 | );
17 |
--------------------------------------------------------------------------------
/src/GlobalValues.re:
--------------------------------------------------------------------------------
1 | [@bs.val] external globalThis: Js.Dict.t(string) = "globalThis";
2 | [@bs.val] external globalThisJson: Js.Dict.t(Js.Json.t) = "globalThis";
3 |
4 | let unsafeAdd = (globalValues: array((string, string))) => {
5 | globalValues->Js.Array2.forEach(((key, value)) => {
6 | globalThis->Js.Dict.set(key, value)
7 | });
8 | };
9 |
10 | let unsafeAddJson = (globalValues: array((string, Js.Json.t))) => {
11 | globalValues->Js.Array2.forEach(((key, value)) => {
12 | globalThisJson->Js.Dict.set(key, value)
13 | });
14 | };
15 |
--------------------------------------------------------------------------------
/src/bindings/Process.re:
--------------------------------------------------------------------------------
1 | type process;
2 |
3 | [@bs.val] external process: process = "process";
4 |
5 | [@bs.send] external exit': (process, int) => 'a = "exit";
6 |
7 | [@bs.get] external argv: process => array(string) = "argv";
8 |
9 | let exit = int => process->exit'(int);
10 |
11 | let getArgs = () => process->argv;
12 |
13 | [@bs.val] external env: Js.Dict.t(string) = "process.env";
14 |
15 | [@bs.send] external on: (process, string, unit => unit) => unit = "on";
16 |
17 | let onTerminate = callback =>
18 | [|"SIGINT", "SIGTERM"|]
19 | ->Js.Array2.forEach(signal => process->on(signal, callback));
20 |
--------------------------------------------------------------------------------
/src/Debounce.re:
--------------------------------------------------------------------------------
1 | let debounce = (~delayMs: int, func: unit => unit) => {
2 | let timeoutId = ref(None);
3 |
4 | let cancel = () => {
5 | (timeoutId^)
6 | ->Belt.Option.forEach(timeoutId => Js.Global.clearTimeout(timeoutId));
7 | timeoutId := None;
8 | };
9 |
10 | let schedule = () => {
11 | cancel();
12 | timeoutId :=
13 | Some(
14 | Js.Global.setTimeout(
15 | () => {
16 | func();
17 | timeoutId := None;
18 | },
19 | delayMs,
20 | ),
21 | );
22 | };
23 |
24 | let debounced = () => schedule();
25 |
26 | debounced;
27 | };
28 |
--------------------------------------------------------------------------------
/example/src/PerPageGlobals.re:
--------------------------------------------------------------------------------
1 | [@val] external globalThis: Js.Dict.t(Js.Json.t) = "globalThis";
2 |
3 | let renderJsonOption = (json: option(Js.Json.t)) =>
4 | switch (json) {
5 | | None => "NONE"->React.string
6 | | Some(json) =>
7 | Js.Json.stringifyAny(json)
8 | ->(Belt.Option.getWithDefault(""))
9 | ->React.string
10 | };
11 |
12 | [@react.component]
13 | let make = () =>
14 |
15 |
16 | "PER_PAGE_GLOBAL_1:"->React.string
17 | {globalThis->(Js.Dict.get("PER_PAGE_GLOBAL_1"))->renderJsonOption}
18 |
19 |
20 | "PER_PAGE_GLOBAL_2:"->React.string
21 | {globalThis->(Js.Dict.get("PER_PAGE_GLOBAL_2"))->renderJsonOption}
22 |
23 |
;
24 |
--------------------------------------------------------------------------------
/src/bindings/ReactHelmet.re:
--------------------------------------------------------------------------------
1 | [@react.component] [@bs.module "react-helmet"]
2 | external make: (~children: React.element) => React.element = "Helmet";
3 |
4 | type helmetProperty = {
5 | //
6 | toString: unit => string,
7 | };
8 |
9 | type helmetInstance = {
10 | base: helmetProperty,
11 | bodyAttributes: helmetProperty,
12 | htmlAttributes: helmetProperty,
13 | link: helmetProperty,
14 | meta: helmetProperty,
15 | noscript: helmetProperty,
16 | script: helmetProperty,
17 | style: helmetProperty,
18 | title: helmetProperty,
19 | };
20 |
21 | // https://github.com/nfl/react-helmet#server-usage
22 |
23 | [@bs.module "react-helmet"] [@bs.scope "Helmet"]
24 | external renderStatic: unit => helmetInstance = "renderStatic";
25 |
--------------------------------------------------------------------------------
/example/src/PageDynamic.re:
--------------------------------------------------------------------------------
1 | let modulePath = Utils.getFilepath();
2 |
3 | [@react.component]
4 | let make = () => {
5 | let url =
6 | RescriptReactRouter.useUrl(
7 | ~serverUrl={
8 | path: ["page-without-data", "server-id"],
9 | hash: "",
10 | search: "",
11 | },
12 | (),
13 | );
14 |
15 | let path =
16 | url.path
17 | ->Belt.List.reverse
18 | ->Belt.List.head
19 | ->(Belt.Option.getWithDefault("None(unexpected)"));
20 |
21 | <>
22 |
23 |
24 | "Dynamic path part: "->React.string path->React.string
25 |
26 |
27 | >;
28 | };
29 |
--------------------------------------------------------------------------------
/example/src/commands/BuildHelper.re:
--------------------------------------------------------------------------------
1 | let build = (~webpackMinimizer) =>
2 | Commands.build(
3 | ~pageAppArtifactsType=Js,
4 | ~pages=Pages.pages,
5 | ~globalEnvValues=Pages.globalEnvValues,
6 | ~webpackMode=Production,
7 | ~outputDir=Pages.outputDir,
8 | ~projectRootDir=Pages.projectRootDir,
9 | ~logLevel=Info,
10 | ~compileCommand=
11 | Path.join2(Pages.projectRootDir, "node_modules/.bin/rescript"),
12 | ~webpackMinimizer,
13 | ~webpackBundleAnalyzerMode=
14 | Some(Static({reportHtmlFilepath: "webpack-bundle/index.html"})),
15 | ~buildWorkersCount=1,
16 | ~pageAppArtifactsSuffix=UnixTimestamp,
17 | (),
18 | )
19 | ->Promise.map(_ => Js.log("[rescript-ssg] Build success!"))
20 | ->ignore;
21 |
--------------------------------------------------------------------------------
/bsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rescript-ssg",
3 | "namespace": true,
4 | "version": "0.0.4",
5 | "sources": [
6 | {
7 | "dir": "src",
8 | "subdirs": true
9 | },
10 | {
11 | "dir": "example",
12 | "type" : "dev",
13 | "subdirs": true
14 | },
15 | {
16 | "dir": "tests",
17 | "type" : "dev",
18 | "subdirs": true
19 | }
20 | ],
21 | "package-specs": {
22 | "module": "es6",
23 | "in-source": true
24 | },
25 | "suffix": ".bs.js",
26 | "reason": {
27 | "react-jsx": 3
28 | },
29 | "bs-dependencies": [
30 | "@rescript/react",
31 | "bs-css",
32 | "bs-css-emotion"
33 | ],
34 | "warnings": {
35 | "number": "+A-4-20-102",
36 | "error": "+A"
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/example/src/PageWithPartialHydration.re:
--------------------------------------------------------------------------------
1 | let modulePath = Utils.getFilepath();
2 |
3 | module Local = {
4 | [@react.component]
5 | let make = () => "THIS_SHOULD_BE_HYDRATED"->React.string
;
6 | };
7 |
8 | [@react.component]
9 | let make = () =>
10 | <>
11 |
15 |
16 | "THIS_SHOULD_NOT_BE_HYDRATED"->React.string
17 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | >;
26 |
--------------------------------------------------------------------------------
/src/bindings/Array.re:
--------------------------------------------------------------------------------
1 | [@bs.send]
2 | external flat1: (array(array('a)), [@bs.as 1] _) => array('a) = "flat";
3 |
4 | [@bs.send]
5 | external flat2: (array(array(array('a))), [@bs.as 2] _) => array('a) =
6 | "flat";
7 |
8 | let splitIntoChunks = (array: array('a), ~chunkSize): array(array('a)) => {
9 | let length = Js.Array2.length(array);
10 | let chunksCount = length / chunkSize + (length mod chunkSize > 0 ? 1 : 0);
11 | let tempArray = Belt.Array.make(chunksCount, ());
12 | let (_, acc) =
13 | tempArray->Js.Array2.reduce(
14 | ((rest, acc), _tempChunk) => {
15 | let chunk = rest->Js.Array2.slice(~start=0, ~end_=chunkSize);
16 | let rest = rest->Js.Array2.sliceFrom(chunkSize);
17 | let newAcc = Js.Array2.concat(acc, [|chunk|]);
18 | (rest, newAcc);
19 | },
20 | (array, [||]),
21 | );
22 | acc;
23 | };
24 |
--------------------------------------------------------------------------------
/example/src/Header.re:
--------------------------------------------------------------------------------
1 | [@react.component]
2 | let make = (~h1Text) => {
3 | Global_Css.injectGlobal();
4 |
5 |
6 |
h1Text->React.string
7 |
"Page examples:"->React.string
8 |
30 |
;
31 | };
32 |
--------------------------------------------------------------------------------
/src/GracefulShutdown.re:
--------------------------------------------------------------------------------
1 | let gracefulShutdownTimeout = 3000;
2 |
3 | type shutdownRunningTask = unit => Js.Promise.t(unit);
4 |
5 | let runningTasks: ref(array(shutdownRunningTask)) = ref([||]);
6 |
7 | let addTask = (task: shutdownRunningTask) => {
8 | runningTasks := Js.Array2.concat([|task|], runningTasks^);
9 | };
10 |
11 | let shutdownRunningTasks = () =>
12 | (runningTasks^)->Js.Array2.map(terminate => terminate())->Promise.all;
13 |
14 | Process.onTerminate(() => {
15 | Js.log("[rescript-ssg] Performing graceful shutdown...");
16 |
17 | shutdownRunningTasks()
18 | ->Promise.map(_ => {
19 | Js.log(
20 | "[rescript-ssg] Bye-bye! Graceful shutdown performed successfully",
21 | );
22 | Process.exit(0);
23 | })
24 | ->Promise.catch(error => {
25 | Js.Console.error2("[rescript-ssg] Graceful shutdown error:", error);
26 | Process.exit(1);
27 | })
28 | ->ignore;
29 | });
30 |
--------------------------------------------------------------------------------
/src/Log.re:
--------------------------------------------------------------------------------
1 | let makeMinimalPrintablePageObj = (~pagePath: PagePath.t, ~pageModulePath) => {
2 | let pagePath = PagePath.toString(pagePath);
3 | {"Page path": pagePath, "Page module path": pageModulePath};
4 | };
5 |
6 | type level =
7 | | Info
8 | | Debug;
9 |
10 | let levelToIndex = (level: level) =>
11 | switch (level) {
12 | | Info => 0
13 | | Debug => 1
14 | };
15 |
16 | let log = (~logLevel: level, ~messageLevel: level, log: unit => unit) => {
17 | let logLevelNum = levelToIndex(logLevel);
18 | let levelIndexNum = levelToIndex(messageLevel);
19 | if (logLevelNum >= levelIndexNum) {
20 | log();
21 | };
22 | };
23 |
24 | type logger = {
25 | logLevel: level,
26 | info: (unit => unit) => unit,
27 | debug: (unit => unit) => unit,
28 | };
29 |
30 | let makeLogger = (logLevel: level) => {
31 | {
32 | logLevel,
33 | info: log(~logLevel, ~messageLevel=Info),
34 | debug: log(~logLevel, ~messageLevel=Debug),
35 | };
36 | };
37 |
--------------------------------------------------------------------------------
/src/bindings/ChildProcess.re:
--------------------------------------------------------------------------------
1 | // https://nodejs.org/api/child_process.html#child_processspawnsynccommand-args-options
2 |
3 | type jsError;
4 |
5 | type spawnSyncOutput = {
6 | status: Js.Nullable.t(int),
7 | error: Js.Nullable.t(jsError),
8 | };
9 |
10 | [@bs.module "node:child_process"]
11 | external spawnSync': (. string, array(string), Js.t('a)) => spawnSyncOutput =
12 | "spawnSync";
13 |
14 | type error =
15 | | JsError(jsError)
16 | | ExitCodeIsNotZero(int);
17 |
18 | let spawnSync = (command, args, options) => {
19 | let result = spawnSync'(. command, args, options);
20 |
21 | let jsError = result.error->Js.Nullable.toOption;
22 |
23 | switch (jsError) {
24 | | Some(e) => Belt.Result.Error(JsError(e))
25 | | None =>
26 | let exitCode =
27 | result.status->Js.Nullable.toOption->Belt.Option.getWithDefault(0);
28 | if (exitCode != 0) {
29 | Belt.Result.Error(ExitCodeIsNotZero(exitCode));
30 | } else {
31 | Ok();
32 | };
33 | };
34 | };
35 |
--------------------------------------------------------------------------------
/src/bindings/Crypto.re:
--------------------------------------------------------------------------------
1 | module Hash = {
2 | type crypto;
3 |
4 | type hash;
5 |
6 | [@bs.module "node:crypto"] external crypto: crypto = "default";
7 |
8 | [@bs.send "createHash"]
9 | external createHash': (crypto, string) => hash = "createHash";
10 |
11 | [@bs.send "update"]
12 | external updateBufferWithBuffer: (hash, Buffer.t) => hash = "update";
13 |
14 | [@bs.send "update"]
15 | external updateBufferWithString: (hash, string) => hash = "update";
16 |
17 | [@bs.send "digest"] external digest: (hash, string) => string = "digest";
18 |
19 | let digestLength = 20;
20 |
21 | let createMd5 = () => crypto->createHash'("md5");
22 |
23 | let bufferToHash = (data: Buffer.t) =>
24 | createMd5()
25 | ->updateBufferWithBuffer(data)
26 | ->digest("hex")
27 | ->Js.String2.slice(~from=0, ~to_=digestLength);
28 |
29 | let stringToHash = (data: string) =>
30 | createMd5()
31 | ->updateBufferWithString(data)
32 | ->digest("hex")
33 | ->Js.String2.slice(~from=0, ~to_=digestLength);
34 | };
35 |
--------------------------------------------------------------------------------
/src/Bin.re:
--------------------------------------------------------------------------------
1 | let dirname = Utils.getDirname();
2 |
3 | let nodeLoaderPath = Path.join2(dirname, "./NodeLoader.bs.js");
4 |
5 | let nodeOptions = [|
6 | {j|--experimental-loader=$(nodeLoaderPath)|j},
7 | "--no-warnings",
8 | |];
9 |
10 | let run = () => {
11 | switch (
12 | ChildProcess.spawnSync(
13 | "node",
14 | Js.Array2.concat(
15 | nodeOptions,
16 | Process.getArgs()->Js.Array2.sliceFrom(2),
17 | ),
18 | {"shell": true, "encoding": "utf8", "stdio": "inherit"},
19 | )
20 | ) {
21 | | Ok () => ()
22 | | Error(JsError(error)) =>
23 | Js.Console.error2("[rescript-ssg Bin] Error:\n", error);
24 | Process.exit(1);
25 | | Error(ExitCodeIsNotZero(exitCode)) =>
26 | Js.Console.error2(
27 | "[rescript-ssg Bin] Failure! Exit code is not zero:",
28 | exitCode,
29 | );
30 | Process.exit(1);
31 | | exception (Js.Exn.Error(error)) =>
32 | Js.Console.error2(
33 | "[rescript-ssg Bin] Exception:\n",
34 | error->Js.Exn.message,
35 | );
36 | Process.exit(1);
37 | };
38 | };
39 |
--------------------------------------------------------------------------------
/src/bindings/Chokidar.re:
--------------------------------------------------------------------------------
1 | type chokidar;
2 |
3 | type watcher;
4 |
5 | [@bs.module "chokidar"] external chokidar: chokidar = "default";
6 |
7 | [@bs.send] external watchFile: (chokidar, string) => watcher = "watch";
8 |
9 | [@bs.send]
10 | external watchFiles: (chokidar, array(string)) => watcher = "watch";
11 |
12 | [@bs.send] external onEvent: (watcher, string, string => unit) => unit = "on";
13 |
14 | [@bs.send]
15 | external onEventWithUnitCallback: (watcher, string, unit => unit) => unit =
16 | "on";
17 |
18 | [@bs.send] external add: (watcher, array(string)) => unit = "add";
19 |
20 | [@bs.send] external unwatch: (watcher, array(string)) => unit = "unwatch";
21 |
22 | [@bs.send]
23 | external getWatched: (watcher, unit) => array(string) = "getWatched";
24 |
25 | let onChange = (chokidar, callback) => chokidar->onEvent("change", callback);
26 |
27 | let onUnlink = (chokidar, callback) => chokidar->onEvent("unlink", callback);
28 |
29 | let onReady = (chokidar, callback) =>
30 | chokidar->onEventWithUnitCallback("ready", callback);
31 |
32 | let onAdd = (chokidar, callback) =>
33 | chokidar->onEventWithUnitCallback("add", callback);
34 |
--------------------------------------------------------------------------------
/src/bindings/HashWasm.re:
--------------------------------------------------------------------------------
1 | // https://github.com/Daninet/hash-wasm#api
2 |
3 | [@bs.module "hash-wasm"] external md5: Buffer.t => Promise.t(string) = "md5";
4 |
5 | // interface IHasher {
6 | // init: () => IHasher;
7 | // update: (data: IDataType) => IHasher;
8 | // digest: (outputType: 'hex' | 'binary') => string | Uint8Array; // by default returns hex string
9 | // save: () => Uint8Array; // returns the internal state for later resumption
10 | // load: (state: Uint8Array) => IHasher; // loads a previously saved internal state
11 | // blockSize: number; // in bytes
12 | // digestSize: number; // in bytes
13 | // }
14 |
15 | type hasher = {
16 | init: (. unit) => hasher,
17 | update: (. Buffer.t) => hasher,
18 | digest: (. string) => Buffer.t,
19 | save: (. unit) => Buffer.t,
20 | load: (. Buffer.t) => hasher,
21 | };
22 |
23 | [@bs.module "hash-wasm"]
24 | external createXXHash64: unit => Promise.t(hasher) = "createXXHash64";
25 |
26 | let createXXHash64AndReturnBinaryDigest = (buffer: Buffer.t) => {
27 | createXXHash64()
28 | ->Promise.map(hasher =>
29 | hasher.init(.).update(. buffer).digest(. "binary")
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/bindings/WorkerThreads.re:
--------------------------------------------------------------------------------
1 | type parentPort;
2 |
3 | type worker;
4 |
5 | [@bs.module "node:worker_threads"] external workerData: 'a = "workerData";
6 |
7 | [@bs.module "node:worker_threads"]
8 | external parentPort: parentPort = "parentPort";
9 |
10 | module Worker = {
11 | type workerDataArg('a) = {workerData: 'a};
12 |
13 | [@bs.new] [@bs.module "node:worker_threads"]
14 | external make: (string, workerDataArg('a)) => worker = "Worker";
15 |
16 | [@bs.send] external on: (worker, string, 'a) => unit = "on";
17 | };
18 |
19 | [@bs.send] external postMessage: (parentPort, 'a) => unit = "postMessage";
20 |
21 | // Compiler doesn't know what will be returned by runWorker function.
22 | // We need to carefully annotate the call of this function in place.
23 | let runWorker = (~workerModulePath, ~workerData: 'a, ~onExit: int => unit) => {
24 | Promise.make((~resolve, ~reject) => {
25 | let worker = Worker.make(workerModulePath, {workerData: workerData});
26 |
27 | worker->Worker.on("message", message => resolve(. message));
28 | worker->Worker.on("error", error => reject(. error));
29 | worker->Worker.on("exit", exitCode => onExit(exitCode));
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/example/src/Page.re:
--------------------------------------------------------------------------------
1 | type t =
2 | | PageWithoutData
3 | | PageWithData
4 | | PageWithoutDataAndWrapperWithoutData
5 | | PageWithoutDataAndWrapperWithData
6 | | PageWithDataAndWrapperWithoutData
7 | | PageWithDataAndWrapperWithData
8 | | PageWithPartialHydration
9 | | PageWithoutHydration;
10 |
11 | let toSlug = page =>
12 | switch (page) {
13 | | PageWithoutData => "page-without-data"
14 | | PageWithData => "page-with-data"
15 | | PageWithoutDataAndWrapperWithoutData => "page-without-data-and-wrapper-without-data"
16 | | PageWithoutDataAndWrapperWithData => "page-without-data-and-wrapper-with-data"
17 | | PageWithDataAndWrapperWithoutData => "page-with-data-and-wrapper-without-data"
18 | | PageWithDataAndWrapperWithData => "page-with-data-and-wrapper-with-data"
19 | | PageWithPartialHydration => "page-with-partial-hydration"
20 | | PageWithoutHydration => "page-without-hydration"
21 | };
22 |
23 | let all = [|
24 | PageWithoutData,
25 | PageWithData,
26 | PageWithoutDataAndWrapperWithoutData,
27 | PageWithoutDataAndWrapperWithData,
28 | PageWithDataAndWrapperWithoutData,
29 | PageWithDataAndWrapperWithData,
30 | PageWithPartialHydration,
31 | PageWithoutHydration,
32 | |];
33 |
--------------------------------------------------------------------------------
/tests/fixtures/TestPageWithData.re:
--------------------------------------------------------------------------------
1 | let modulePath = Utils.getFilepath();
2 |
3 | type variant =
4 | | A
5 | | B(string);
6 |
7 | type polyVariant = [ | `hello | `world];
8 |
9 | type data = {
10 | string,
11 | int,
12 | float,
13 | variant,
14 | polyVariant,
15 | bool,
16 | option: option(string),
17 | };
18 |
19 | [@react.component]
20 | let make = (~data: option(data)) =>
21 |
22 | {switch (data) {
23 | | None => React.null
24 | | Some({bool, string, int, float, variant, polyVariant, option}) =>
25 | <>
26 | {bool->string_of_bool->React.string}
27 | string->React.string
28 | {int->Belt.Int.toString->React.string}
29 | {float->Belt.Float.toString->React.string}
30 | {switch (variant) {
31 | | A => "A"->React.string
32 | | B(_) => "B"->React.string
33 | }}
34 | {switch (polyVariant) {
35 | | `hello => "hello"->React.string
36 | | `world => "world"->React.string
37 | }}
38 | {switch (option) {
39 | | None => "None"->React.string
40 | | Some(s) => ("Some: " ++ s)->React.string
41 | }}
42 | >
43 | }}
44 |
;
45 |
--------------------------------------------------------------------------------
/src/bindings/Fs.re:
--------------------------------------------------------------------------------
1 | [@bs.module "node:fs"]
2 | external readFileSync': (~path: string, ~encoding: string) => string =
3 | "readFileSync";
4 |
5 | [@bs.module "node:fs"]
6 | external readFileSyncAsBuffer: string => Buffer.t = "readFileSync";
7 |
8 | [@bs.module "node:fs"]
9 | external writeFileSync: (~path: string, ~data: string) => unit =
10 | "writeFileSync";
11 |
12 | [@bs.module "node:fs"] external existsSync: string => bool = "existsSync";
13 |
14 | type mkDirOptions = {recursive: bool};
15 |
16 | [@bs.module "node:fs"]
17 | external mkDirSync: (string, mkDirOptions) => unit = "mkdirSync";
18 |
19 | type rmSyncOptions = {
20 | force: bool,
21 | recursive: bool,
22 | };
23 |
24 | [@bs.module "node:fs"]
25 | external rmSync: (string, rmSyncOptions) => unit = "rmSync";
26 |
27 | let readFileSyncAsUtf8 = path => readFileSync'(~path, ~encoding="utf8");
28 |
29 | module Promises = {
30 | [@bs.module "node:fs/promises"]
31 | external readFileAsBuffer: string => Promise.t(Buffer.t) = "readFile";
32 |
33 | [@bs.module "node:fs/promises"]
34 | external mkDir: (string, mkDirOptions) => Promise.t(unit) = "mkdir";
35 |
36 | [@bs.module "node:fs/promises"]
37 | external writeFile: (~path: string, ~data: string) => Promise.t(unit) =
38 | "writeFile";
39 | };
40 |
--------------------------------------------------------------------------------
/tests/fixtures/TestWrapperWithData.re:
--------------------------------------------------------------------------------
1 | type variant =
2 | | A
3 | | B(string);
4 |
5 | type polyVariant = [ | `hello | `world];
6 |
7 | type data = {
8 | string,
9 | int,
10 | float,
11 | variant,
12 | polyVariant,
13 | bool,
14 | option: option(string),
15 | };
16 |
17 | [@react.component]
18 | let make = (~data: option(data), ~children) =>
19 |
20 |
"Hello from page wrapper with data"->React.string
21 |
22 | {switch (data) {
23 | | None => React.null
24 | | Some({bool, string, int, float, variant, polyVariant, option}) =>
25 | <>
26 | {bool->string_of_bool->React.string}
27 | string->React.string
28 | {int->Belt.Int.toString->React.string}
29 | {float->Belt.Float.toString->React.string}
30 | {switch (variant) {
31 | | A => "A"->React.string
32 | | B(_) => "B"->React.string
33 | }}
34 | {switch (polyVariant) {
35 | | `hello => "hello"->React.string
36 | | `world => "world"->React.string
37 | }}
38 | {switch (option) {
39 | | None => "None"->React.string
40 | | Some(s) => ("Some: " ++ s)->React.string
41 | }}
42 | >
43 | }}
44 |
45 | children
46 |
;
47 |
48 | let modulePath = Utils.getFilepath();
49 |
--------------------------------------------------------------------------------
/example/src/Content.re:
--------------------------------------------------------------------------------
1 | module Css = Content_Css;
2 |
3 | [@bs.module "./images/cat.jpeg"] external catImage: string = "default";
4 |
5 | [@bs.module "./content.css"] external css: string = "default";
6 | css->ignore;
7 |
8 | [@bs.module "lite-flag-icon/css/flag-icon.min.css"]
9 | external flagsCss: string = "default";
10 | flagsCss->ignore;
11 |
12 | [@react.component]
13 | let make = () => {
14 | let (isFlagVisible, setIsFlagVisible) = React.useState(() => false);
15 |
16 |
17 |
"Imported image file:"->React.string
18 |
19 |
20 | "Flag from imported external CSS lib: "->React.string
21 |
22 |
23 |
"Button styled via imported CSS:"->React.string
24 |
33 | {if (isFlagVisible) {
34 |
;
35 | } else {
36 | React.null;
37 | }}
38 |
"ENV_VAR: "->React.string Env.envVar->React.string
39 |
"GLOBAL_VAR: "->React.string Env.globalVar->React.string
40 |
41 |
;
42 | };
43 |
--------------------------------------------------------------------------------
/src/PartialHydration.re:
--------------------------------------------------------------------------------
1 | let makeScriptId = (~moduleName) => {
2 | // TODO hashify it
3 | let modulePrefix = moduleName->Js.String2.replaceByRe([%re {|/\./g|}], "_");
4 | "withHydration__" ++ modulePrefix;
5 | };
6 |
7 | let renderReactAppTemplate = (~modulesWithHydration__Mutable: array(string)) => {
8 | modulesWithHydration__Mutable
9 | ->Js.Array2.map(moduleName => {
10 | let scriptId = makeScriptId(~moduleName);
11 | {j|
12 | switch (ReactDOM.querySelector("#$(scriptId)")) {
13 | | Some(root) => ReactDOM.hydrate(<$(moduleName) />, root)
14 | | None => ()
15 | };
16 | |j};
17 | })
18 | ->Js.Array2.joinWith("\n");
19 | };
20 |
21 | module WithHydrationContext = {
22 | let default: array(string) = [||];
23 |
24 | let context = React.createContext(default);
25 |
26 | module Provider = {
27 | let provider = React.Context.provider(context);
28 |
29 | [@react.component]
30 | let make = (~modulesWithHydration__Mutable: array(string), ~children) => {
31 | React.createElement(
32 | provider,
33 | {"value": modulesWithHydration__Mutable, "children": children},
34 | );
35 | };
36 | };
37 | };
38 |
39 | module WithHydration = {
40 | [@react.component]
41 | let make = (~moduleName, ~children) => {
42 | let modulesWithHydration = React.useContext(WithHydrationContext.context);
43 |
44 | let () = modulesWithHydration->Js.Array2.push(moduleName)->ignore;
45 |
46 | children
;
47 | };
48 | };
49 |
--------------------------------------------------------------------------------
/src/Bundler.re:
--------------------------------------------------------------------------------
1 | type t =
2 | | Webpack
3 | | Esbuild;
4 |
5 | type mode =
6 | | Build
7 | | Watch;
8 |
9 | let fromString = (bundler: string) =>
10 | switch (bundler) {
11 | | "webpack" => Webpack
12 | | "esbuild" => Esbuild
13 | | _ => Webpack
14 | };
15 |
16 | let bundler =
17 | Process.env
18 | ->Js.Dict.get("RESCRIPT_SSG_BUNDLER")
19 | ->Belt.Option.getWithDefault("")
20 | ->Js.String2.toLowerCase
21 | ->fromString;
22 |
23 | let assetsDirname = "assets";
24 |
25 | let assetFileExtensions = [|
26 | "css",
27 | "jpg",
28 | "jpeg",
29 | "png",
30 | "gif",
31 | "svg",
32 | "ico",
33 | "avif",
34 | "webp",
35 | "woff",
36 | "woff2",
37 | "json",
38 | "mp4",
39 | |];
40 |
41 | let assetFileExtensionsWithoutCss =
42 | assetFileExtensions->Js.Array2.filter(ext => ext !== "css");
43 |
44 | let assetRegex = {
45 | let regex: string = assetFileExtensions->Js.Array2.joinWith("|");
46 | let regex = {|\.|} ++ "(" ++ regex ++ ")" ++ "$";
47 | Js.Re.fromStringWithFlags(regex, ~flags="i");
48 | };
49 |
50 | let getGlobalEnvValuesDict = (globalEnvValues: array((string, string))) => {
51 | let dict = Js.Dict.empty();
52 |
53 | globalEnvValues->Js.Array2.forEach(((key, value)) => {
54 | let value = {j|"$(value)"|j};
55 | dict->Js.Dict.set(key, value);
56 | });
57 |
58 | dict;
59 | };
60 |
61 | let getOutputDir = (~outputDir) => Path.join2(outputDir, "public");
62 |
63 | let assetPrefix =
64 | EnvParams.assetPrefix->Utils.maybeAddSlashPrefix->Utils.maybeAddSlashSuffix;
65 |
--------------------------------------------------------------------------------
/src/BuildPageWorkerT.re:
--------------------------------------------------------------------------------
1 | // Here we have almost the same type as in PageBuilder module but type constructor doesn't accept functions,
2 | // because we can't pass functions to workers.
3 | // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
4 |
5 | type componentWithData('a) = {data: 'a};
6 |
7 | type wrapperComponent =
8 | | WrapperWithChildren
9 | | WrapperWithDataAndChildren(componentWithData('a)): wrapperComponent;
10 |
11 | type pageWrapper = {
12 | component: wrapperComponent,
13 | modulePath: string,
14 | };
15 |
16 | type component =
17 | | ComponentWithoutData
18 | | ComponentWithData(componentWithData('a)): component;
19 |
20 | type workerPage = {
21 | hydrationMode: PageBuilder.hydrationMode,
22 | pageWrapper: option(pageWrapper),
23 | component,
24 | modulePath: string,
25 | headCssFilepaths: array(string),
26 | path: PagePath.t,
27 | globalValues: option(array((string, Js.Json.t))),
28 | headScripts: array(string),
29 | bodyScripts: array(string),
30 | };
31 |
32 | type workerData = {
33 | pageAppArtifactsType: PageBuilder.pageAppArtifactsType,
34 | outputDir: string,
35 | melangeOutputDir: option(string),
36 | logLevel: Log.level,
37 | pages: array(workerPage),
38 | globalEnvValues: array((string, string)),
39 | pageAppArtifactsSuffix: string,
40 | };
41 |
42 | let showPage = (page: workerPage) => {
43 | PagePath.toString(page.path);
44 | };
45 |
46 | let showPages = (pages: array(workerPage)) => {
47 | pages->Js.Array2.map(page => page->showPage);
48 | };
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rescript-ssg",
3 | "description": "Rescript React static site generator",
4 | "homepage": "https://github.com/denis-ok/rescript-ssg",
5 | "version": "1.9.0",
6 | "engines": {
7 | "node": ">=18"
8 | },
9 | "bin": {
10 | "rescript-ssg": "./src/js/bin.mjs"
11 | },
12 | "scripts": {},
13 | "type": "module",
14 | "keywords": [
15 | "rescript",
16 | "react"
17 | ],
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/denis-ok/rescript-ssg.git"
21 | },
22 | "author": "Denis Strelkov",
23 | "license": "MIT",
24 | "dependencies": {
25 | "@craftamap/esbuild-plugin-html": "https://github.com/denis-ok/esbuild-plugin-html#79f512f447eb98efa6b6786875f617a095eaaf09",
26 | "base32-encode": "^2.0.0",
27 | "chokidar": "^3.5.3",
28 | "css-loader": "^6.8.1",
29 | "esbuild": "^0.19.10",
30 | "esbuild-loader": "^4.0.2",
31 | "hash-wasm": "^4.11.0",
32 | "html-webpack-plugin": "^5.5.3",
33 | "jsesc": "^3.0.2",
34 | "mini-css-extract-plugin": "^2.7.6",
35 | "webpack": "^5.88.2",
36 | "webpack-bundle-analyzer": "^4.9.1",
37 | "webpack-dev-server": "^4.15.1"
38 | },
39 | "peerDependencies": {
40 | "@emotion/css": "^11.10.0",
41 | "@emotion/server": "^11.10.0",
42 | "@rescript/react": "^0.10.3",
43 | "bs-css-emotion": "^5.0.0",
44 | "react": "^17.0.2",
45 | "react-dom": "^17.0.2",
46 | "react-helmet": "^6.1.0"
47 | },
48 | "devDependencies": {
49 | "c8": "^8.0.1",
50 | "lite-flag-icon": "^1.0.8",
51 | "rescript": "^9.1.4",
52 | "serve": "^14.2.1"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/example/src/PageWithData.re:
--------------------------------------------------------------------------------
1 | let modulePath = Utils.getFilepath();
2 |
3 | [@react.component]
4 | let make = (~data: option(PageContext.t)) =>
5 | <>
6 |
7 |
8 |
9 | {switch (data) {
10 | | None => React.null
11 | | Some({string, int, float, bool, variant, polyVariant, option}) =>
12 |
13 | - "string: "->React.string string->React.string
14 | -
15 | "int: "->React.string
16 | {int->Belt.Int.toString->React.string}
17 |
18 | -
19 | "float: "->React.string
20 | {float->Belt.Float.toString->React.string}
21 |
22 | -
23 | "bool: "->React.string
24 | {bool->string_of_bool->React.string}
25 |
26 | -
27 | "variant: "->React.string
28 | {switch ((variant: PageContext.variant)) {
29 | | One => "One"->React.string
30 | | Two(_) => "Two"->React.string
31 | }}
32 |
33 | -
34 | "polyVariant: "->React.string
35 | {switch ((polyVariant: PageContext.polyVariant)) {
36 | | `hello => "hello"->React.string
37 | | `world => "world"->React.string
38 | }}
39 |
40 | -
41 | "option(string): "->React.string
42 | {switch (option) {
43 | | None => "None"->React.string
44 | | Some(s) => ("Some " ++ s)->React.string
45 | }}
46 |
47 |
48 | }}
49 |
50 |
51 |
52 | >;
53 |
--------------------------------------------------------------------------------
/src/bindings/Emotion.re:
--------------------------------------------------------------------------------
1 | // Unused bindings commented out
2 |
3 | // type cache;
4 |
5 | // module CacheProvider = {
6 | // [@react.component] [@bs.module "@emotion/react"]
7 | // external make: (~value: cache, ~children: React.element) => React.element =
8 | // "CacheProvider";
9 | // };
10 |
11 | // module Css = {
12 | // [@bs.module "@emotion/css"] external defaultCache: cache = "cache";
13 | // };
14 |
15 | // Custom cache doesn't work for some reason. But is it needed? Seems not.
16 | // Looks like the same issue: https://github.com/emotion-js/emotion/issues/2731
17 | // Default cache works fine.
18 |
19 | // module Cache = {
20 | // type createCacheInput = {key: string};
21 | // [@bs.module "@emotion/cache/dist/emotion-cache.cjs.js"] [@bs.scope "default"]
22 | // external createCache: createCacheInput => cache = "default";
23 | // };
24 |
25 | module Server = {
26 | type extractCriticalResult = {
27 | html: string,
28 | css: string,
29 | ids: array(string),
30 | };
31 |
32 | // type createEmotionServerResult = {
33 | // extractCritical: string => extractCriticalResult,
34 | // };
35 |
36 | // All exports from "@emotion/server" index are the results of internal calling "createEmotionServer(cache)"
37 | // where passed cache is default cache imported from "@emotion/css".
38 |
39 | [@bs.module "@emotion/server"]
40 | external extractCritical: string => extractCriticalResult =
41 | "extractCritical";
42 |
43 | [@bs.module "@emotion/server"]
44 | external renderStylesToString: string => string = "renderStylesToString";
45 | // Below is a function to build emotion server manually with a custom cache.
46 | // [@bs.module
47 | // "@emotion/server/create-instance/dist/emotion-server-create-instance.cjs.js"
48 | // ]
49 | // [@bs.scope "default"]
50 | // external createEmotionServer: cache => createEmotionServerResult = "default";
51 | };
52 |
--------------------------------------------------------------------------------
/src/bindings/Promise.re:
--------------------------------------------------------------------------------
1 | include Js.Promise;
2 |
3 | [@bs.send]
4 | external map: (Js.Promise.t('a), 'a => 'b) => Js.Promise.t('b) = "then";
5 |
6 | [@bs.send]
7 | external flatMap:
8 | (Js.Promise.t('a), 'a => Js.Promise.t('b)) => Js.Promise.t('b) =
9 | "then";
10 |
11 | [@bs.send]
12 | external catch:
13 | (Js.Promise.t('a), Js.Promise.error => Js.Promise.t('b)) =>
14 | Js.Promise.t('b) =
15 | "catch";
16 |
17 | let seqRun = (functions: array(unit => Js.Promise.t('a))) => {
18 | Js.Array2.reduce(
19 | functions,
20 | (acc, func) => {
21 | switch (acc) {
22 | | [] => [func()]
23 | | [promise, ...rest] => [
24 | promise->flatMap(_ => func()),
25 | promise,
26 | ...rest,
27 | ]
28 | }
29 | },
30 | [],
31 | )
32 | ->Belt.List.toArray
33 | ->Js.Promise.all;
34 | };
35 |
36 | module Result = {
37 | let catch =
38 | (promise, ~context: string)
39 | : Js.Promise.t(Belt.Result.t('ok, (string, Js.Promise.error))) =>
40 | promise
41 | ->map(value => Belt.Result.Ok(value))
42 | ->catch(error => Belt.Result.Error((context, error))->Js.Promise.resolve);
43 |
44 | let all = (promises: Js.Promise.t(array(Belt.Result.t('ok, 'error)))) =>
45 | promises->map(promises => {
46 | let (oks, errors) =
47 | promises->Js.Array2.reduce(
48 | ((oks, errors), result) =>
49 | switch (result) {
50 | | Ok(ok) => (Js.Array2.concat([|ok|], oks), errors)
51 | | Error(error) => (oks, Js.Array2.concat([|error|], errors))
52 | },
53 | ([||], [||]),
54 | );
55 |
56 | switch (errors) {
57 | | [||] => Ok(oks)
58 | | _ => Error(errors)
59 | };
60 | });
61 |
62 | let map =
63 | (promise: Js.Promise.t(Belt.Result.t('a, 'error)), func: 'a => 'b) =>
64 | promise->map(result => result->Belt.Result.map(func));
65 |
66 | let flatMap =
67 | (
68 | promise: Js.Promise.t(Belt.Result.t('a, 'error)),
69 | func: 'a => Js.Promise.t('b),
70 | ) =>
71 | promise->flatMap(result =>
72 | switch (result) {
73 | | Ok(ok) => func(ok)
74 | | Error(error) => Js.Promise.resolve(Error(error))
75 | }
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/src/Utils.re:
--------------------------------------------------------------------------------
1 | type jsError;
2 |
3 | [@bs.new] external makeError: unit => jsError = "Error";
4 |
5 | [@bs.get] external getStack: jsError => string = "stack";
6 |
7 | [@bs.val] external window: _ = "window";
8 |
9 | // Commented to avoid error in webpack
10 | // @module("path") external dirnameFromFilepath: string => string = "dirname"
11 |
12 | let dirnameFromFilepath = filepath => {
13 | filepath
14 | ->Js.String2.split("/")
15 | ->Js.Array2.slice(~start=0, ~end_=-1)
16 | ->Js.Array2.joinWith("/");
17 | };
18 |
19 | // Reusable functions that can be simply called from any module instead of
20 | // dealing with import.meta.url etc.
21 |
22 | let getFilepathFromError = jsError => {
23 | let lineWithPath =
24 | jsError
25 | ->getStack
26 | ->Js.String2.split("\n")
27 | ->Js.Array2.slice(~start=2, ~end_=3)
28 | ->Belt.Array.get(0);
29 |
30 | switch (lineWithPath) {
31 | | None => Js.Exn.raiseError("[getFilepathFromError] lineWithPath is None")
32 | | Some(lineWithPath) =>
33 | lineWithPath
34 | ->Js.String2.trim
35 | ->Js.String2.replace("at file://", "")
36 | ->Js.String2.replaceByRe(Js.Re.fromString(":[0-9]+:[0-9]+"), "")
37 | };
38 | };
39 |
40 | let getFilepath = () =>
41 | switch (Js.typeof(window) == "undefined") {
42 | // Get filepath only in node
43 | | false => ""
44 | | true => makeError()->getFilepathFromError
45 | };
46 |
47 | let getDirname = () => makeError()->getFilepathFromError->dirnameFromFilepath;
48 |
49 | let getModuleNameFromModulePath = modulePath => {
50 | let segments = modulePath->Js.String2.split("/");
51 |
52 | let filename =
53 | segments->Js.Array2.copy->Js.Array2.reverseInPlace->Belt.Array.get(0);
54 |
55 | switch (filename) {
56 | | None
57 | | Some("") =>
58 | Js.Console.error(
59 | "[Utils.getModuleNameFromModulePath] Filename is empty or None",
60 | );
61 | Process.exit(1);
62 | | Some(filename) => filename->Js.String2.replace(".bs.js", "")
63 | };
64 | };
65 |
66 | let maybeAddSlashPrefix = path =>
67 | if (path->Js.String2.startsWith("http") || path->Js.String2.startsWith("/")) {
68 | path;
69 | } else {
70 | "/" ++ path;
71 | };
72 |
73 | let maybeAddSlashSuffix = path =>
74 | if (path->Js.String2.endsWith("/")) {
75 | path;
76 | } else {
77 | path ++ "/";
78 | };
79 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 1.9.0
2 | - Put bundle `.js` files to `assets/js` dir.
3 | - Fix issues with `asset-prefix` parameter.
4 | - Initialize tests. https://github.com/denis-ok/rescript-ssg/commit/89a831147f487923db83e78c55bb797008daca60
5 | - Update dependencies, remove unused `webpack-cli` dep.
6 | - Provide env vars to browser via webpack define plugin. https://github.com/denis-ok/rescript-ssg/commit/3222bad9c39a895a928c5aaa30c493ef69944ff3
7 | - Add `writeWebpackStatsJson` flag to disable writing webpack `stats.json`. Now disabled by default.
8 | - Catch error in `RebuildPageWorker` to avoid stopping of Webpack/watcher after renaming files. https://github.com/denis-ok/rescript-ssg/commit/cde6b81b09db11f552b9d0f1ef100fbc82c8c659
9 | - Allow choosing minimizer for production bundle build. `Terser` is Webpack default, `Esbuild` is an alternative/experimental option added via `esbuild-loader` (it's fast). https://github.com/denis-ok/rescript-ssg/commit/21eb9d450d603267bcbd5742bf0e412591fae991
10 |
11 | ## 1.8.0
12 |
13 | - Inject emotion styles to `` instead on inline [Commit](https://github.com/denis-ok/rescript-ssg/commit/aa2a47b254a2ca0ffc33f5cad0a4d7ae4b2a1176).
14 | - Build React App files with ReScript syntax.
15 | - Remove `webpackOutputDir` param [Commit](https://github.com/denis-ok/rescript-ssg/commit/e051e769cfdaed50ec2ef4dcf9a4a5b5b23e4e20).
16 | - Tweak webpack chunks [Commit](https://github.com/denis-ok/rescript-ssg/commit/2a9d44a8f398a6f721eeabbd7e1b048efd33d252).
17 |
18 | ## 1.7.0
19 |
20 | - Add `headCssFilepaths` field to inject CSS to page's ``, [PR](https://github.com/denis-ok/rescript-ssg/pull/8).
21 | - Refactor watcher, rebuild pages on demand with a debounced function instead of checking queue with `setInterval`, [Commit](https://github.com/denis-ok/rescript-ssg/commit/b5331109834d998cef144c1d26bc7f995accebd6).
22 | - Improve/refactor logging, add `logLevel` parameter to build/start commands, [PR](https://github.com/denis-ok/rescript-ssg/pull/9).
23 |
24 | ## 1.6.0
25 |
26 | - Add `headCss` field to page to inject CSS into result HTML.
27 | - Minify HTML in production mode.
28 | - Refactor node-loader.
29 | - Reorganize modules, remove unused code, some refactoring.
30 |
31 | ## 1.5.0
32 |
33 | - Add `aggregateTimeout` option to Webpack config to avoid too often rebuilding.
34 | - Move some dependencies to peerDependencies.
35 | - Bump `webpack-dev-server` dependency.
36 |
37 | ## 1.4.0
38 |
39 | - Format files, add `.css` to asset regex.
40 |
41 | ## 1.3.0
42 |
43 | - Fix path to node loader in binary.
44 |
45 | ## 1.2.0
46 |
47 | - Make node-loader compatible with prev node versions.
48 |
49 | ## 1.1.0
50 |
51 | - Add binary file that runs node with experimental loader.
52 |
53 | ## 1.0.0
54 |
55 | - Switch to major version.
56 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | MAKEFILE_DIR = $(dir $(realpath $(lastword $(MAKEFILE_LIST))))
2 |
3 | NODE_BINS = node_modules/.bin
4 |
5 | EXAMPLE_DIR = example
6 |
7 | RESCRIPT_SSG_BIN = ENV_VAR=FOO ./src/js/bin.mjs
8 |
9 | COMMANDS_DIR = $(EXAMPLE_DIR)/src/commands
10 |
11 | .PHONY: clean-rescript
12 | clean-rescript:
13 | $(NODE_BINS)/rescript clean -with-deps
14 |
15 | .PHONY: build-rescript
16 | build-rescript:
17 | $(NODE_BINS)/rescript
18 |
19 | .PHONY: start-rescript
20 | start-rescript:
21 | mkdir $(EXAMPLE_DIR)/build; \
22 | $(NODE_BINS)/rescript build -w
23 |
24 | .PHONY: build-example
25 | build-example:
26 | PROJECT_ROOT_DIR=$(MAKEFILE_DIR) RESCRIPT_SSG_BUNDLER=esbuild $(RESCRIPT_SSG_BIN) $(COMMANDS_DIR)/Build.bs.js
27 |
28 | .PHONY: start-example
29 | start-example:
30 | PROJECT_ROOT_DIR=$(MAKEFILE_DIR) RESCRIPT_SSG_BUNDLER=esbuild $(RESCRIPT_SSG_BIN) $(COMMANDS_DIR)/Start.bs.js
31 |
32 | .PHONY: serve-example
33 | serve-example:
34 | $(NODE_BINS)/serve -l 3005 $(EXAMPLE_DIR)/build/public
35 |
36 | .PHONY: clean-example
37 | clean-example:
38 | rm -rf $(EXAMPLE_DIR)/build
39 | mkdir $(EXAMPLE_DIR)/build
40 |
41 | .PHONY: clean
42 | clean:
43 | make clean-test
44 | make clean-rescript
45 | make clean-example
46 |
47 | .PHONY: build-webpack
48 | build-webpack: clean
49 | make build-rescript
50 | make build-example
51 |
52 | .PHONY: build-esbuild build
53 | build-esbuild build: clean
54 | make build-rescript
55 | RESCRIPT_SSG_BUNDLER=esbuild make build-example
56 |
57 | .PHONY: build-ci
58 | build-ci: clean
59 | make build-rescript
60 | make test
61 | make clean-test
62 | make build-esbuild
63 | make clean-example
64 | PROJECT_ROOT_DIR=$(MAKEFILE_DIR) $(RESCRIPT_SSG_BIN) $(COMMANDS_DIR)/BuildWithTerser.bs.js
65 | make clean-example
66 | PROJECT_ROOT_DIR=$(MAKEFILE_DIR) $(RESCRIPT_SSG_BIN) $(COMMANDS_DIR)/BuildWithEsbuildPlugin.bs.js
67 | make clean-example
68 | PROJECT_ROOT_DIR=$(MAKEFILE_DIR) $(RESCRIPT_SSG_BIN) $(COMMANDS_DIR)/BuildWithTerserPluginWithEsbuild.bs.js
69 |
70 | .PHONY: build-serve
71 | build-serve:
72 | make build-esbuild
73 | make serve-example
74 |
75 | .PHONY: build-serve-webpack
76 | build-serve-webpack:
77 | make build-webpack
78 | make serve-example
79 |
80 | .PHONY: start
81 | start: clean build-rescript
82 | make -j 2 start-rescript start-example
83 |
84 | .PHONY: init-dev
85 | init-dev:
86 | rm -rf _opam
87 | opam switch create . 4.06.1 --deps-only
88 |
89 | .PHONY: format-reason
90 | format-reason:
91 | @$(NODE_BINS)/bsrefmt --in-place -w 80 \
92 | $(shell find ./src ./example -type f \( -name *.re -o -name *.rei \))
93 |
94 | .PHONY: format-rescript
95 | format-rescript:
96 | @$(NODE_BINS)/rescript format -all
97 |
98 | .PHONY: format
99 | format:
100 | make format-reason
101 | make format-rescript
102 |
103 | .PHONY: clean-test
104 | clean-test:
105 | rm -rf tests/output
106 | rm -rf coverage
107 |
108 | .PHONY: test
109 | test: clean-test
110 | $(NODE_BINS)/c8 node ./tests/Tests.bs.js
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # rescript-ssg
2 | `rescript-ssg` is ReScript library to build static websites with React (rescript-react).
3 |
4 | ## Features and basic info
5 | - Simple. Everything is explicit and passed via function arguments.
6 | - Designed to work with `bs-css`/`bs-emotion`.
7 | - Designed to work with ES modules projects.
8 |
9 | ## How it works?
10 | 1. You create a separate React components for your pages.
11 | 2. `rescript-ssg` renders HTML templates and creates ReScript-React app files from page components.
12 | 3. New ReScript files are feed to ReScript compiler.
13 | 4. Webpack consumes rendered HTML + complied file to create a bundle per page and collect static assets.
14 |
15 | ## Installation
16 |
17 | ```bash
18 | npm install --save-dev rescript-ssg
19 | ```
20 |
21 | Add `rescript-ssg` to `bs-dependencies` in your `bsconfig.json`:
22 |
23 | ```json
24 | {
25 | "bs-dependencies": [
26 | "rescript-ssg",
27 | ],
28 | }
29 | ```
30 |
31 | ## Basic usage
32 | 1. Create `Index.res` page component:
33 |
34 | ```rescript
35 | @react.component
36 | let make = () => {
37 | React.string "Hello from index page"
38 | }
39 |
40 | // This helper call gets a filepath of this module
41 | let modulePath = RescriptSsg.Utils.getFilepath()
42 | ```
43 |
44 | 2. Create `Pages.res` file where we'll define our pages array:
45 |
46 | ```rescript
47 | let currentDir = RescriptSsg.Utils.getDirname()
48 |
49 | let outputDir = RescriptSsg.Path.join2(currentDir, "../build")
50 |
51 | let index: RescriptSsg.PageBuilder.page = {
52 | hydrationMode: FullHydration,
53 | pageWrapper: None,
54 | component: ComponentWithoutData(),
55 | modulePath: Index.modulePath,
56 | headCssFilepaths: [],
57 | path: Root,
58 | globalValues:⋅None,
59 | headScripts:⋅[],
60 | bodyScripts:⋅[]
61 | }
62 |
63 | let pages = [index]
64 | ```
65 |
66 | 3. Create `Build.res` file. We'll pass this file to `rescript-ssg` binary to perform build.
67 |
68 | ```rescript
69 | let currentDir = RescriptSsg.Utils.getDirname()
70 |
71 | let () = RescriptSsg.Commands.build(
72 | ~mode=Production,
73 | ~outputDir=Pages.outputDir,
74 | ~logLevel=Info,
75 | ~compileCommand=Path.join2(currentDir, "../node_modules/.bin/rescript"),
76 | ~pages=Pages.pages,
77 | ~webpackBundleAnalyzerMode=Some(Static({reportHtmlFilepath:⋅"webpack-bundle/index.html"})),
78 | ~minimizer=Terser,
79 | ~globalEnvValues=[],
80 | ~buildWorkersCount=32
81 | (),
82 | )
83 | ```
84 |
85 | 4. Create `Start.res` file. We'll pass this file to `rescript-ssg` binary to start dev mode.
86 |
87 | ```rescript
88 | let () = RescriptSsg.Commands.start(
89 | ~devServerOptions={listenTo: Port(9000), proxy: None},
90 | ~mode=Development,
91 | ~globalEnvValues=Pages.globalEnvValues,
92 | ~webpackBundleAnalyzerMode=None,
93 | ~outputDir=Pages.outputDir,
94 | ~logLevel=Info,
95 | ~pages=Pages.pages,
96 | (),
97 | )
98 | ```
99 |
100 | 5. Make sure you have `"type": "module"` in `package.json` and update `scripts` field:
101 |
102 | ```json
103 | {
104 | "type": "module",
105 | "scripts": {
106 | "build-rescript-ssg": "rescript-ssg src/Build.bs.js",
107 | "start-rescript-ssg": "rescript-ssg src/Start.bs.js"
108 | },
109 | }
110 | ```
111 |
112 | 6. Update the `sources` field in `bsconfig.json`. We need to add `outputDir` there to compile intermediate React App files generated by `rescript-ssg`:
113 |
114 | ```json
115 | {
116 | "sources": [
117 | {
118 | "dir": "build",
119 | "type" : "dev",
120 | "subdirs": true
121 | }
122 | ],
123 | }
124 | ```
125 |
126 | 7. Finally, we can run commands.
127 | - To start development mode:
128 | Start ReScript compiler in a watch mode in the first terminal tab.
129 | Then run in a second tab:
130 |
131 | ```bash
132 | npm run start-rescript-ssg
133 | ```
134 |
135 | - To build pages:
136 |
137 | ```bash
138 | npm run build-rescript-ssg
139 | ```
140 |
141 | 8. After successfull build you'll see two directories in your specified output dir: `public` and `temp`. `public` dir is what you want to serve. It contains a bundle and static assets.
142 |
--------------------------------------------------------------------------------
/src/NodeLoader.re:
--------------------------------------------------------------------------------
1 | [@bs.send]
2 | external replaceAll: (string, string, string) => string = "replaceAll";
3 |
4 | let bsArtifactRegex = [%re {|/file:.*\.bs\.js$/i|}];
5 |
6 | let isBsArtifact = fileUrl => {
7 | switch (Js.String2.match(fileUrl, bsArtifactRegex)) {
8 | | Some(_) => true
9 | | None => false
10 | };
11 | };
12 |
13 | let isAsset = fileUrl => {
14 | switch (Js.String2.match(fileUrl, Bundler.assetRegex)) {
15 | | Some(_) => true
16 | | None => false
17 | };
18 | };
19 |
20 | // Getting a hash of the file contents the same way as it implemented in esbuild.
21 | // 1. Calc xxhash64 from binary data and return digest as binary data.
22 | // Source: https://github.com/evanw/esbuild/blob/main/internal/bundler/bundler.go#L2191-L2195
23 | // 2. Encode binary data to base32.
24 | // Source: https://github.com/evanw/esbuild/blob/main/internal/bundler/bundler.go#L1084-L1086
25 | // Side note: the author of esbuild doesn't want to export hash function to use it from js.
26 | // Maybe it makes sense to raise this question again.
27 | // Source: https://github.com/evanw/esbuild/issues/3113#issuecomment-1542394482
28 | let getEsbuildFileHash = (buffer: Buffer.t) => {
29 | HashWasm.createXXHash64AndReturnBinaryDigest(buffer)
30 | ->Promise.map(buffer => {
31 | Base32Encode.base32Encode(buffer)->Js.String2.slice(~from=0, ~to_=8)
32 | });
33 | };
34 |
35 | // We get a file's hash and make a JS module that exports a filename with hash suffix.
36 | let getFinalHashedAssetPath = (url: string) => {
37 | let filePath = url->Js.String2.replace("file://", "");
38 |
39 | filePath
40 | ->Fs.Promises.readFileAsBuffer
41 | ->Promise.catch(error => {
42 | Js.Console.error2(
43 | "[NodeLoader.getFinalHashedAssetPath] [Fs.Promises.readFileAsBuffer] Error:",
44 | error->Util.inspect,
45 | );
46 | Process.exit(1);
47 | })
48 | ->Promise.flatMap(fileData => {
49 | let fileName = Path.basename(url);
50 |
51 | let fileExt = Path.extname(fileName);
52 |
53 | let filenameWithoutExt = fileName->Js.String2.replace(fileExt, "");
54 |
55 | let filenameWithHash =
56 | switch (Bundler.bundler) {
57 | | Webpack =>
58 | let fileHash = Crypto.Hash.bufferToHash(fileData);
59 | Promise.resolve(filenameWithoutExt ++ "." ++ fileHash ++ fileExt);
60 | | Esbuild =>
61 | // cat-FU5UU3XL.jpeg
62 | getEsbuildFileHash(fileData)
63 | ->Promise.map(fileHash => {
64 | filenameWithoutExt ++ "-" ++ fileHash ++ fileExt
65 | })
66 | ->Promise.catch(error => {
67 | Js.Console.error2(
68 | "[NodeLoader.getFinalHashedAssetPath] [Esbuild.getFileHash] Error:",
69 | error->Util.inspect,
70 | );
71 | Process.exit(1);
72 | })
73 | };
74 |
75 | filenameWithHash->Promise.map(filenameWithHash => {
76 | let assetPath =
77 | switch (EnvParams.assetPrefix->Js.String2.startsWith("https://")) {
78 | | false =>
79 | let assetsDir =
80 | Path.join2(EnvParams.assetPrefix, Bundler.assetsDirname);
81 | Path.join2(assetsDir, filenameWithHash);
82 | | true =>
83 | let assetsDir =
84 | EnvParams.assetPrefix ++ "/" ++ Bundler.assetsDirname;
85 | assetsDir ++ "/" ++ filenameWithHash;
86 | };
87 |
88 | let assetPath = Utils.maybeAddSlashPrefix(assetPath);
89 | assetPath;
90 | });
91 | })
92 | ->Promise.catch(error => {
93 | Js.Console.error2(
94 | "[NodeLoader.getFinalHashedAssetPath] Unexpected promise rejection:",
95 | error->Util.inspect,
96 | );
97 | Process.exit(1);
98 | });
99 | };
100 |
101 | let makeAssetSource = (webpackAssetPath: string) => {j|export default "$(webpackAssetPath)"|j};
102 |
103 | let processAsset = (url: string) => {
104 | getFinalHashedAssetPath(url)
105 | ->Promise.map(webpackAssetPath =>
106 | {
107 | "format": "module",
108 | "source": makeAssetSource(webpackAssetPath),
109 | "shortCircuit": true,
110 | }
111 | );
112 | };
113 |
114 | let load =
115 | (
116 | url,
117 | _context,
118 | nextLoad:
119 | (. string, option({. "format": string})) =>
120 | Js.Promise.t({
121 | .
122 | "format": string,
123 | "shortCircuit": bool,
124 | "source": string,
125 | }),
126 | ) =>
127 | if (isBsArtifact(url)) {
128 | // We need to fix the error that appeared after bs-css added:
129 | // /Users/denis/projects/builder/node_modules/bs-css-emotion/src/Css.bs.js:3
130 | // import * as Curry from "rescript/lib/es6/curry.js";
131 | // ^^^^^^
132 | // SyntaxError: Cannot use import statement outside a module
133 | // We force NodeJS to load bs-artifacts as es6 modules
134 | let format = "module";
135 | nextLoad(. url, Some({"format": format}));
136 | } else if (isAsset(url)) {
137 | processAsset(url);
138 | } else {
139 | nextLoad(. url, None);
140 | };
141 |
--------------------------------------------------------------------------------
/example/src/Pages.re:
--------------------------------------------------------------------------------
1 | // It's more reliable to have a constant for the project root directory and build paths relative to it
2 | // instead of building paths relative to the directory of the current module.
3 | // In the case of Melange, JS files are emitted to a different directory with a different nesting structure
4 | // which can lead to issues. So better to use project root dir as the base.
5 |
6 | [@val]
7 | external projectRootDir': option(string) = "process.env.PROJECT_ROOT_DIR";
8 |
9 | let projectRootDir =
10 | switch (projectRootDir') {
11 | | Some(dir) => dir
12 | | _ =>
13 | Js.Console.error("PROJECT_ROOT_DIR env var is missing");
14 | Process.exit(1);
15 | };
16 |
17 | let outputDir = Path.join2(projectRootDir, "example/build");
18 |
19 | let normalizeCssFilePath =
20 | Path.join2(projectRootDir, "example/src/css/normalize.css");
21 |
22 | let globalEnvValues = [|
23 | ("process.env.ENV_VAR", Env.envVar),
24 | ("GLOBAL_VAR", "BAR"),
25 | |];
26 |
27 | let wrapperWithoutData: PageBuilder.pageWrapper = (
28 | {
29 | PageBuilder.component:
30 | WrapperWithChildren(
31 | children => children ,
32 | ),
33 | modulePath: WrapperWithoutData.modulePath,
34 | }: PageBuilder.pageWrapper
35 | );
36 |
37 | let wrapperWithData: PageBuilder.pageWrapper = (
38 | {
39 | component:
40 | WrapperWithDataAndChildren({
41 | component: (data, children) =>
42 | children ,
43 | data: "LALA \"escaped quotes\"",
44 | }),
45 | modulePath: WrapperWithData.modulePath,
46 | }: PageBuilder.pageWrapper
47 | );
48 |
49 | let pageWithoutData: PageBuilder.page = (
50 | {
51 | hydrationMode: FullHydration,
52 | pageWrapper: None,
53 | component: ComponentWithoutData(),
54 | modulePath: PageWithoutData.modulePath,
55 | headCssFilepaths: [|normalizeCssFilePath|],
56 | path: Path([|Page.toSlug(PageWithoutData)|]),
57 | globalValues:
58 | Some([|
59 | ("PER_PAGE_GLOBAL_1", "ONE!"->Js.Json.string),
60 | ("PER_PAGE_GLOBAL_2", "TWO!"->Js.Json.string),
61 | |]),
62 | headScripts: [||],
63 | bodyScripts: [||],
64 | }: PageBuilder.page
65 | );
66 |
67 | let pageWithData: PageBuilder.page = (
68 | {
69 | hydrationMode: FullHydration,
70 | pageWrapper: None,
71 | component:
72 | ComponentWithData({
73 | component: data => ,
74 | data:
75 | Some({
76 | string: "foo \"bar\" baz",
77 | int: 1,
78 | float: 1.23,
79 | variant: One,
80 | polyVariant: `hello,
81 | option: Some("lalala"),
82 | bool: true,
83 | }),
84 | }),
85 | modulePath: PageWithData.modulePath,
86 | headCssFilepaths: [|normalizeCssFilePath|],
87 | path: Path([|Page.toSlug(PageWithData)|]),
88 | globalValues: None,
89 | headScripts: [||],
90 | bodyScripts: [||],
91 | }: PageBuilder.page
92 | );
93 |
94 | let pageWithoutDataAndWrapperWithoutData = {
95 | ...pageWithoutData,
96 | pageWrapper: Some(wrapperWithoutData),
97 | path: Path([|Page.toSlug(PageWithoutDataAndWrapperWithoutData)|]),
98 | };
99 |
100 | let pageWithoutDataAndWrapperWithData = {
101 | ...pageWithoutData,
102 | pageWrapper: Some(wrapperWithData),
103 | path: Path([|Page.toSlug(PageWithoutDataAndWrapperWithData)|]),
104 | };
105 |
106 | let pageWithDataAndWrapperWithoutData = {
107 | ...pageWithData,
108 | pageWrapper: Some(wrapperWithoutData),
109 | path: Path([|Page.toSlug(PageWithDataAndWrapperWithoutData)|]),
110 | };
111 |
112 | let pageWithDataAndWrapperWithData = {
113 | ...pageWithData,
114 | pageWrapper: Some(wrapperWithData),
115 | path: Path([|Page.toSlug(PageWithDataAndWrapperWithData)|]),
116 | };
117 |
118 | let pageWithoutDataDynamicPath: PageBuilder.page = (
119 | {
120 | ...pageWithoutData,
121 | component: ComponentWithoutData(),
122 | modulePath: PageDynamic.modulePath,
123 | path: Path([|Page.toSlug(PageWithoutData), PagePath.dynamicSegment|]),
124 | }: PageBuilder.page
125 | );
126 |
127 | let pageWithoutDataRegularPath: PageBuilder.page = (
128 | {
129 | ...pageWithoutData,
130 | component: ComponentWithoutData(),
131 | modulePath: PageDynamic.modulePath,
132 | // This page is needed to test esbuild watch mode.
133 | // We need to make sure that proxy server doesn't handle this path as a path with dynamic segment.
134 | // It makes sense to add a test for this case.
135 | path: Path([|Page.toSlug(PageWithoutData), "foo"|]),
136 | }: PageBuilder.page
137 | );
138 |
139 | let pageWithPartialHydration: PageBuilder.page = (
140 | {
141 | ...pageWithoutData,
142 | hydrationMode: PartialHydration,
143 | component: ComponentWithoutData(),
144 | modulePath: PageWithPartialHydration.modulePath,
145 | path: Path([|Page.toSlug(PageWithPartialHydration)|]),
146 | }: PageBuilder.page
147 | );
148 |
149 | let pageWithoutHydration: PageBuilder.page = (
150 | {
151 | ...pageWithoutData,
152 | hydrationMode: PartialHydration,
153 | component: ComponentWithoutData(),
154 | modulePath: PageWithoutHydration.modulePath,
155 | path: Path([|Page.toSlug(PageWithoutHydration)|]),
156 | }: PageBuilder.page
157 | );
158 |
159 | let pages = [|
160 | {...pageWithoutData, path: Root},
161 | pageWithoutData,
162 | pageWithoutDataAndWrapperWithoutData,
163 | pageWithoutDataAndWrapperWithData,
164 | pageWithData,
165 | pageWithDataAndWrapperWithoutData,
166 | pageWithDataAndWrapperWithData,
167 | pageWithoutDataDynamicPath,
168 | pageWithoutDataRegularPath,
169 | pageWithPartialHydration,
170 | pageWithoutHydration,
171 | |];
172 |
173 | let fakeExtralanguages = [|"es"|];
174 |
175 | let localizedPages =
176 | Js.Array2.map(fakeExtralanguages, language =>
177 | Js.Array2.map(pages, page =>
178 | {
179 | ...page,
180 | path:
181 | switch (page.path) {
182 | | Root => Path([|language|])
183 | | Path(segments) =>
184 | Path(Js.Array2.concat([|language|], segments))
185 | },
186 | }
187 | )
188 | );
189 |
190 | let pages = Js.Array2.concat([|pages|], localizedPages);
191 |
--------------------------------------------------------------------------------
/src/BuildPageWorkerHelpers.re:
--------------------------------------------------------------------------------
1 | let dirname = Utils.getDirname();
2 |
3 | let mapPageToPageForRebuild =
4 | (~page: PageBuilder.page): BuildPageWorkerT.workerPage => {
5 | {
6 | hydrationMode: page.hydrationMode,
7 | pageWrapper: {
8 | switch (page.pageWrapper) {
9 | | None => None
10 | | Some({component: WrapperWithChildren(_), modulePath}) =>
11 | Some({component: WrapperWithChildren, modulePath})
12 | | Some({
13 | component: PageBuilder.WrapperWithDataAndChildren({data, _}),
14 | modulePath,
15 | }) =>
16 | Some({
17 | component: WrapperWithDataAndChildren({data: data}),
18 | modulePath,
19 | })
20 | };
21 | },
22 | component: {
23 | switch (page.component) {
24 | | ComponentWithoutData(_) => ComponentWithoutData
25 | | PageBuilder.ComponentWithData({data, _}) =>
26 | ComponentWithData({data: data})
27 | };
28 | },
29 | modulePath: page.modulePath,
30 | headCssFilepaths: page.headCssFilepaths,
31 | path: page.path,
32 | globalValues: page.globalValues,
33 | headScripts: page.headScripts,
34 | bodyScripts: page.bodyScripts,
35 | };
36 | };
37 |
38 | let runBuildPageWorker =
39 | (~onExit, ~workerData: BuildPageWorkerT.workerData)
40 | : BuildPageWorker.workerOutput =>
41 | // This is the place where we have to manually annotate output type of runWorker call
42 | WorkerThreads.runWorker(
43 | ~workerModulePath=Path.join2(dirname, "BuildPageWorker.bs.js"),
44 | ~workerData,
45 | ~onExit,
46 | );
47 |
48 | let buildPagesWithWorker =
49 | (
50 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType,
51 | ~outputDir: string,
52 | ~melangeOutputDir: option(string),
53 | ~logger: Log.logger,
54 | ~globalEnvValues: array((string, string)),
55 | ~pageAppArtifactsSuffix: string,
56 | pages: array(PageBuilder.page),
57 | ) => {
58 | let rebuildPages =
59 | pages->Js.Array2.map(page => mapPageToPageForRebuild(~page));
60 |
61 | let workerData: BuildPageWorkerT.workerData = {
62 | pageAppArtifactsType,
63 | outputDir,
64 | melangeOutputDir,
65 | pages: rebuildPages,
66 | logLevel: logger.logLevel,
67 | globalEnvValues,
68 | pageAppArtifactsSuffix,
69 | };
70 |
71 | runBuildPageWorker(~workerData, ~onExit=exitCode => {
72 | logger.debug(() => Js.log2("[Worker] Exit code:", exitCode))
73 | });
74 | };
75 |
76 | let defaultWorkersCount = 8;
77 |
78 | let buildPagesWithWorkers =
79 | (
80 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType,
81 | ~pages: array(array(PageBuilder.page)),
82 | ~outputDir: string,
83 | ~melangeOutputDir: option(string),
84 | ~logger: Log.logger,
85 | ~globalEnvValues: array((string, string)),
86 | ~buildWorkersCount: option(int),
87 | ~exitOnPageBuildError: bool,
88 | ~pageAppArtifactsSuffix: string,
89 | )
90 | : Js.Promise.t(array(RenderedPage.t)) => {
91 | let buildWorkersCount =
92 | switch (buildWorkersCount) {
93 | | None =>
94 | switch (NodeOs.availableParallelism) {
95 | | None => defaultWorkersCount
96 | | Some(f) => min(f(), defaultWorkersCount)
97 | }
98 | | Some(buildWorkersCount) => buildWorkersCount
99 | };
100 |
101 | let minPagesCountForSplitting = 500;
102 |
103 | // We expect that user passed already chunked array of pages: array(array(pages))
104 | // where each array(pages) is a chunk for one worker thread.
105 | // The most common case is when user wants to build localized website and there are two chunks for two locales.
106 | // But we can have a situation when one locale contains too many pages and it's a good idea to split
107 | // user defined chunk into smaller chunks to spawn more workers and parallelize rendering.
108 | // For example user has 2 locales and 8 cores and and to spawn 8 workers we must do an extra chunk splitting.
109 | let pages =
110 | pages
111 | ->Js.Array2.map(pagesChunk => {
112 | let pagesInChunk = pagesChunk->Js.Array2.length;
113 | switch (pagesInChunk >= minPagesCountForSplitting) {
114 | | false => [|pagesChunk|]
115 | | true =>
116 | let chunksCount = pagesInChunk / minPagesCountForSplitting + 1;
117 | let chunkSize =
118 | pagesInChunk / chunksCount + pagesInChunk mod chunksCount;
119 | pagesChunk->Array.splitIntoChunks(~chunkSize);
120 | };
121 | })
122 | ->Array.flat1;
123 |
124 | logger.info(() =>
125 | Js.log3(
126 | "[Commands.buildPagesWithWorkers] Building pages with ",
127 | buildWorkersCount,
128 | " workers...",
129 | )
130 | );
131 |
132 | let startTime = Performance.now();
133 |
134 | let pagesChunkedForWorkers =
135 | pages->Array.splitIntoChunks(~chunkSize=buildWorkersCount);
136 |
137 | let results =
138 | pagesChunkedForWorkers
139 | ->Js.Array2.map((pagesChunk: array(array(PageBuilder.page))) => {
140 | let buildChunksWithWorkers = () =>
141 | pagesChunk
142 | ->Js.Array2.map(chunk =>
143 | buildPagesWithWorker(
144 | ~pageAppArtifactsType,
145 | ~outputDir,
146 | ~melangeOutputDir,
147 | ~logger,
148 | ~globalEnvValues,
149 | ~pageAppArtifactsSuffix,
150 | chunk,
151 | )
152 | )
153 | ->Promise.all;
154 |
155 | buildChunksWithWorkers;
156 | })
157 | ->Promise.seqRun
158 | ->Promise.map(results => Array.flat2(results))
159 | ->Promise.map(results => {
160 | Js.log2(
161 | "[Commands.buildPagesWithWorkers] Build finished. Duration",
162 | Performance.durationSinceStartTime(~startTime),
163 | );
164 | results;
165 | });
166 |
167 | results->Promise.map(renderedPages =>
168 | renderedPages->Belt.Array.keepMap(result => {
169 | switch (result) {
170 | | Ok(renderedPage) => Some(renderedPage)
171 | | Error(path) =>
172 | Js.Console.error2(
173 | "[Commands.buildPagesWithWorkers] One of the pages failed to build:",
174 | PagePath.toString(path),
175 | );
176 | if (exitOnPageBuildError) {
177 | Process.exit(1);
178 | } else {
179 | None;
180 | };
181 | }
182 | })
183 | );
184 | };
185 |
--------------------------------------------------------------------------------
/src/BuildPageWorker.re:
--------------------------------------------------------------------------------
1 | [@bs.val] external import_: string => Promise.t('a) = "import";
2 |
3 | let showPage = (page: BuildPageWorkerT.workerPage) => {
4 | Log.makeMinimalPrintablePageObj(
5 | ~pagePath=page.path,
6 | ~pageModulePath=page.modulePath,
7 | );
8 | };
9 |
10 | let workerData: BuildPageWorkerT.workerData = WorkerThreads.workerData;
11 |
12 | let parentPort = WorkerThreads.parentPort;
13 |
14 | let pages = workerData.pages;
15 |
16 | let pagesCount: string = workerData.pages->Js.Array2.length->Belt.Int.toString;
17 |
18 | let logger = Log.makeLogger(workerData.logLevel);
19 |
20 | logger.info(() => Js.log({j|[Worker] Building $(pagesCount) pages...|j}));
21 |
22 | type workerOutput =
23 | Promise.t(array(Belt.Result.t(RenderedPage.t, PagePath.t)));
24 |
25 | let startTime = Performance.now();
26 |
27 | let workerOutput: workerOutput =
28 | pages
29 | ->Js.Array2.map(page => {
30 | let moduleName: string =
31 | Utils.getModuleNameFromModulePath(page.modulePath);
32 |
33 | let pagePath: string = page.path->PagePath.toString;
34 |
35 | let pageInfo: string = {j|[Page module: $(moduleName), page path: $(pagePath)]|j};
36 |
37 | let successText = {j|[Worker] $(pageInfo) Build success.|j};
38 |
39 | logger.info(() => {Js.log({j|[Worker] $(pageInfo) Building...|j})});
40 |
41 | logger.debug(() =>
42 | Js.log2("[Worker] Page to build:\n", page->showPage)
43 | );
44 |
45 | let () = GlobalValues.unsafeAdd(workerData.globalEnvValues);
46 |
47 | let () =
48 | page.globalValues
49 | ->Belt.Option.forEach(globalValues =>
50 | GlobalValues.unsafeAddJson(globalValues)
51 | );
52 |
53 | logger.debug(() =>
54 | Js.log2("[Worker] Trying to import page module: ", page.modulePath)
55 | );
56 |
57 | let pageModule = import_(page.modulePath);
58 |
59 | let pageWrapperModule =
60 | switch (page.pageWrapper) {
61 | | None => Promise.resolve(None)
62 | | Some({modulePath, _}) =>
63 | logger.debug(() =>
64 | Js.log2(
65 | "[Worker] Trying to import page wrapper module: ",
66 | modulePath,
67 | )
68 | );
69 | import_(modulePath)->Promise.map(module_ => Some(module_));
70 | };
71 |
72 | let importedModules = Promise.all2((pageModule, pageWrapperModule));
73 |
74 | importedModules
75 | ->Promise.flatMap(((module_, wrapperModule)) => {
76 | let newPage: PageBuilder.page = {
77 | hydrationMode: page.hydrationMode,
78 | pageWrapper: {
79 | switch (page.pageWrapper, wrapperModule) {
80 | | (Some({component, modulePath}), Some(wrapperModule)) =>
81 | switch (component) {
82 | | WrapperWithChildren =>
83 | Some({
84 | component:
85 | WrapperWithChildren(
86 | children =>
87 | React.createElement(
88 | wrapperModule##make,
89 | {"children": children},
90 | ),
91 | ),
92 | modulePath,
93 | })
94 | | BuildPageWorkerT.WrapperWithDataAndChildren({data}) =>
95 | Some({
96 | component:
97 | WrapperWithDataAndChildren({
98 | component: (data, children) =>
99 | React.createElement(
100 | wrapperModule##make,
101 | {"data": data, "children": children}->Obj.magic,
102 | ),
103 | data,
104 | }),
105 | modulePath,
106 | })
107 | }
108 | | _ => None
109 | };
110 | },
111 | component: {
112 | switch (page.component) {
113 | | BuildPageWorkerT.ComponentWithoutData =>
114 | ComponentWithoutData(
115 | React.createElement(module_##make, Js.Obj.empty()),
116 | )
117 | | BuildPageWorkerT.ComponentWithData({data}) =>
118 | ComponentWithData({
119 | component: _propValue => {
120 | React.createElement(
121 | module_##make,
122 | {"data": data}->Obj.magic,
123 | );
124 | },
125 | data,
126 | })
127 | };
128 | },
129 | modulePath: module_##modulePath,
130 | headCssFilepaths: page.headCssFilepaths,
131 | path: page.path,
132 | globalValues: page.globalValues,
133 | headScripts: page.headScripts,
134 | bodyScripts: page.bodyScripts,
135 | };
136 |
137 | PageBuilder.buildPageHtmlAndReactApp(
138 | ~pageAppArtifactsType=workerData.pageAppArtifactsType,
139 | ~outputDir=workerData.outputDir,
140 | ~melangeOutputDir=workerData.melangeOutputDir,
141 | ~logger,
142 | ~pageAppArtifactsSuffix=workerData.pageAppArtifactsSuffix,
143 | newPage,
144 | );
145 | })
146 | ->Promise.map(result => {
147 | switch (result) {
148 | | Ok((renderedPage: RenderedPage.t)) =>
149 | Js.log(successText);
150 | Belt.Result.Ok(renderedPage);
151 | | Error((errors: array((string, Js.Promise.error)))) =>
152 | logger.info(() => {
153 | Js.Console.error2(
154 | {j|[Worker] $(pageInfo) Build page errors:|j},
155 | errors,
156 | )
157 | });
158 | let result = Belt.Result.Error(page.path);
159 | result;
160 | }
161 | })
162 | ->Promise.catch(error => {
163 | // We don't want to immediately stop node process/watcher when something happened in worker.
164 | // We just log the error and the caller will decide what to do.
165 | logger.info(() => {
166 | Js.Console.error2(
167 | {j|[Worker] $(pageInfo) Unexpected promise rejection, please check:|j},
168 | error,
169 | )
170 | });
171 | let result = Belt.Result.Error(page.path);
172 | Promise.resolve(result);
173 | });
174 | })
175 | ->Promise.all
176 | ->Promise.map(pages => {
177 | Js.log2(
178 | "[Worker] Job done! Duration:",
179 | Performance.durationSinceStartTime(~startTime),
180 | );
181 | parentPort->WorkerThreads.postMessage(pages);
182 | pages;
183 | });
184 |
--------------------------------------------------------------------------------
/tests/Tests.re:
--------------------------------------------------------------------------------
1 | let dirname = Utils.getDirname();
2 |
3 | [@val] external process: Js.t('a) = "process";
4 |
5 | [@bs.module] external util: Js.t('a) = "util";
6 |
7 | let inspect = (value): string =>
8 | util##inspect(value, {"compact": false, "depth": 20, "colors": true});
9 |
10 | let exitWithError = () => Process.exit(1);
11 |
12 | let isEqual = (~msg="", v1, v2) =>
13 | if (v1 != v2) {
14 | Js.log2("Test failed:", msg);
15 | Js.log2("expected this:", inspect(v1));
16 | Js.log2("to be equal to:", inspect(v2));
17 | exitWithError();
18 | };
19 |
20 | module Utils_ = {
21 | module GetModuleNameFromModulePath = {
22 | let testName = "Utils.getModuleNameFromModulePath";
23 | let test = modulePath => {
24 | let moduleName = Utils.getModuleNameFromModulePath(modulePath);
25 | isEqual(~msg=testName, moduleName, "TestPage");
26 | };
27 | test("TestPage.bs.js");
28 | test("/TestPage.bs.js");
29 | test("./TestPage.bs.js");
30 | test("/foo/bar/TestPage.bs.js");
31 | test("foo/bar/TestPage.bs.js");
32 | Js.log2(testName, " tests passed!");
33 | };
34 | };
35 |
36 | module MakeReactAppModuleName = {
37 | let moduleName = "Page";
38 |
39 | let test = (~pagePath, ~expect) => {
40 | let reactAppModuleName =
41 | PageBuilder.pagePathToPageAppModuleName(
42 | ~pageAppArtifactsSuffix="",
43 | ~pagePath,
44 | ~moduleName,
45 | );
46 | isEqual(~msg="makeReactAppModuleName", reactAppModuleName, expect);
47 | };
48 |
49 | test(~pagePath=".", ~expect="Page__PageApp");
50 |
51 | test(~pagePath="foo/bar", ~expect="foobarPage__PageApp");
52 |
53 | test(~pagePath="foo/bar-baz", ~expect="foobarbazPage__PageApp");
54 |
55 | Js.log("MakeReactAppModuleName tests passed!");
56 | };
57 |
58 | module BuildPageHtmlAndReactApp = {
59 | let dummyLogger: Log.logger = {
60 | logLevel: Log.Info,
61 | info: ignore,
62 | debug: ignore,
63 | };
64 |
65 | let removeNewlines = (str: string) => {
66 | let regex = Js.Re.fromStringWithFlags({js|[\r\n]+|js}, ~flags="g");
67 | str->Js.String2.replaceByRe(regex, "");
68 | };
69 |
70 | let logger = Log.makeLogger(Info);
71 |
72 | let outputDir = Path.join2(dirname, "output");
73 |
74 | let artifactsOutputDir = PageBuilder.getArtifactsOutputDir(~outputDir);
75 |
76 | let cleanup = () => Fs.rmSync(outputDir, {force: true, recursive: true});
77 |
78 | let compileCommand = Path.join2(dirname, "../node_modules/.bin/rescript");
79 |
80 | let test = (~page, ~expectedAppContent, ~expectedHtmlContent as _) => {
81 | cleanup();
82 |
83 | let renderedPage =
84 | PageBuilder.buildPageHtmlAndReactApp(
85 | ~pageAppArtifactsType=Reason,
86 | ~outputDir,
87 | ~melangeOutputDir=None,
88 | ~logger,
89 | ~pageAppArtifactsSuffix="",
90 | page,
91 | );
92 |
93 | renderedPage->Promise.map(renderedPage => {
94 | switch (renderedPage) {
95 | | Error(errors) =>
96 | Js.Console.error2("Test failed:", errors);
97 | Process.exit(1);
98 | | Ok(_) =>
99 | Commands.compileRescript(~compileCommand, ~logger);
100 |
101 | let moduleName = Utils.getModuleNameFromModulePath(page.modulePath);
102 |
103 | let pagePath: string = page.path->PagePath.toString;
104 |
105 | let reactAppModuleName =
106 | PageBuilder.pagePathToPageAppModuleName(
107 | ~pageAppArtifactsSuffix="",
108 | ~pagePath,
109 | ~moduleName,
110 | );
111 |
112 | let testPageAppContent =
113 | Fs.readFileSyncAsUtf8(
114 | Path.join2(artifactsOutputDir, reactAppModuleName ++ ".re"),
115 | );
116 |
117 | isEqual(
118 | removeNewlines(testPageAppContent),
119 | removeNewlines(expectedAppContent),
120 | );
121 |
122 | let _html =
123 | Fs.readFileSyncAsUtf8(
124 | Path.join2(artifactsOutputDir, "index.html"),
125 | );
126 | ();
127 | }
128 | });
129 | };
130 |
131 | module SimplePage = {
132 | let page: PageBuilder.page = {
133 | hydrationMode: FullHydration,
134 | pageWrapper: None,
135 | component: ComponentWithoutData(),
136 | modulePath: TestPage.modulePath,
137 | headCssFilepaths: [||],
138 | path: Root,
139 | globalValues: None,
140 | headScripts: [||],
141 | bodyScripts: [||],
142 | };
143 |
144 | let expectedAppContent = {js|
145 | switch (ReactDOM.querySelector("#root")) {
146 | | Some(root) => ReactDOM.hydrate(, root)
147 | | None => ()
148 | };
149 | |js};
150 |
151 | let expectedHtmlContent = "";
152 |
153 | let testPromise = () =>
154 | test(~page, ~expectedAppContent, ~expectedHtmlContent);
155 | };
156 |
157 | module PageWithWrapper = {
158 | let page: PageBuilder.page = {
159 | hydrationMode: FullHydration,
160 | pageWrapper:
161 | Some({
162 | component:
163 | WrapperWithChildren(
164 | children => children ,
165 | ),
166 | modulePath: TestWrapper.modulePath,
167 | }),
168 | component: ComponentWithoutData(),
169 | modulePath: TestPage.modulePath,
170 | headCssFilepaths: [||],
171 | path: Root,
172 | globalValues: None,
173 | headScripts: [||],
174 | bodyScripts: [||],
175 | };
176 |
177 | let expectedAppContent = {js|
178 | switch (ReactDOM.querySelector("#root")) {
179 | | Some(root) => ReactDOM.hydrate(, root)
180 | | None => ()
181 | };
182 | |js};
183 | let expectedHtmlContent = "";
184 |
185 | let testPromise = () =>
186 | test(~page, ~expectedAppContent, ~expectedHtmlContent);
187 | };
188 |
189 | module PageWithData = {
190 | let page: PageBuilder.page = {
191 | hydrationMode: FullHydration,
192 | pageWrapper: None,
193 | component:
194 | ComponentWithData({
195 | component: data => ,
196 | data:
197 | Some({
198 | bool: true,
199 | string: "foo",
200 | int: 1,
201 | float: 1.23,
202 | variant: A,
203 | polyVariant: `hello,
204 | option: Some("bar"),
205 | }),
206 | }),
207 | modulePath: TestPageWithData.modulePath,
208 | headCssFilepaths: [||],
209 | path: Root,
210 | globalValues: None,
211 | headScripts: [||],
212 | bodyScripts: [||],
213 | };
214 |
215 | let expectedAppContent = {js|
216 | type pageData;
217 | [@bs.module "./TestPageWithData_Data_688ca4c30fca5edb6793.mjs"] external pageData: pageData = "data";
218 |
219 | switch (ReactDOM.querySelector("#root")) {
220 | | Some(root) => ReactDOM.hydrate(Obj.magic} />, root)
221 | | None => ()
222 | };
223 | |js};
224 | let expectedHtmlContent = "";
225 |
226 | let testPromise = () =>
227 | test(~page, ~expectedAppContent, ~expectedHtmlContent);
228 | };
229 |
230 | module PageWrapperWithDataAndPageWithData = {
231 | let page: PageBuilder.page = {
232 | hydrationMode: FullHydration,
233 | pageWrapper:
234 | Some({
235 | component:
236 | WrapperWithDataAndChildren({
237 | component: (data, children) =>
238 | children ,
239 | data:
240 | Some({
241 | bool: true,
242 | string: "foo",
243 | int: 1,
244 | float: 1.23,
245 | variant: A,
246 | polyVariant: `hello,
247 | option: Some("bar"),
248 | }),
249 | }),
250 | modulePath: TestWrapperWithData.modulePath,
251 | }),
252 | component:
253 | ComponentWithData({
254 | component: data => ,
255 | data:
256 | Some({
257 | bool: true,
258 | string: "foo",
259 | int: 1,
260 | float: 1.23,
261 | variant: A,
262 | polyVariant: `hello,
263 | option: Some("bar"),
264 | }),
265 | }),
266 | modulePath: TestPageWithData.modulePath,
267 | headCssFilepaths: [||],
268 | path: Root,
269 | globalValues: None,
270 | headScripts: [||],
271 | bodyScripts: [||],
272 | };
273 |
274 | let expectedAppContent = {js|
275 | type pageWrapperData;
276 | [@bs.module "./__pageWrappersData/TestWrapperWithData_Data_688ca4c30fca5edb6793.mjs"] external pageWrapperData: pageWrapperData = "data";
277 |
278 | type pageData;
279 | [@bs.module "./TestPageWithData_Data_688ca4c30fca5edb6793.mjs"] external pageData: pageData = "data";
280 |
281 | switch (ReactDOM.querySelector("#root")) {
282 | | Some(root) => ReactDOM.hydrate(Obj.magic} >Obj.magic} />, root)
283 | | None => ()
284 | };
285 | |js};
286 | let expectedHtmlContent = "";
287 |
288 | let testPromise = () =>
289 | test(~page, ~expectedAppContent, ~expectedHtmlContent);
290 | };
291 |
292 | let tests =
293 | [|
294 | SimplePage.testPromise,
295 | PageWithWrapper.testPromise,
296 | PageWithData.testPromise,
297 | PageWrapperWithDataAndPageWithData.testPromise,
298 | |]
299 | ->Promise.seqRun
300 | ->Promise.map(_ => Js.log("BuildPageHtmlAndReactApp tests passed!"))
301 | ->ignore;
302 | };
303 |
--------------------------------------------------------------------------------
/src/Commands.re:
--------------------------------------------------------------------------------
1 | let checkDuplicatedPagePaths = (pages: array(array(PageBuilder.page))) => {
2 | Js.log("[rescript-ssg] Checking duplicated page paths...");
3 |
4 | let pagesDict = Js.Dict.empty();
5 |
6 | pages->Js.Array2.forEach(pages' => {
7 | pages'->Js.Array2.forEach(page => {
8 | let pagePath = PagePath.toString(page.path);
9 | switch (pagesDict->Js.Dict.get(pagePath)) {
10 | | None => pagesDict->Js.Dict.set(pagePath, page)
11 | | Some(_) =>
12 | Js.Console.error2(
13 | "[rescript-ssg] List of pages contains pages with the same paths. Duplicated page path:",
14 | pagePath,
15 | );
16 | Process.exit(1);
17 | };
18 | })
19 | });
20 | };
21 |
22 | let compileRescript = (~compileCommand: string, ~logger: Log.logger) => {
23 | let durationLabel = "[Commands.compileRescript] Success! Duration";
24 | Js.Console.timeStart(durationLabel);
25 |
26 | logger.info(() =>
27 | Js.log("[Commands.compileRescript] Compiling fresh React app files...")
28 | );
29 |
30 | switch (
31 | ChildProcess.spawnSync(
32 | compileCommand,
33 | [||],
34 | {"shell": true, "encoding": "utf8", "stdio": "inherit"},
35 | )
36 | ) {
37 | | Ok () => logger.info(() => Js.Console.timeEnd(durationLabel))
38 | | Error(JsError(error)) =>
39 | logger.info(() => {
40 | Js.Console.error2("[Commands.compileRescript] Failure! Error:", error)
41 | });
42 | Process.exit(1);
43 | | Error(ExitCodeIsNotZero(exitCode)) =>
44 | Js.Console.error2(
45 | "[Commands.compileRescript] Failure! Exit code is not zero:",
46 | exitCode,
47 | );
48 | Process.exit(1);
49 | | exception (Js.Exn.Error(error)) =>
50 | logger.info(() => {
51 | Js.Console.error2(
52 | "[Commands.compileRescript] Exception:\n",
53 | error->Js.Exn.message,
54 | )
55 | });
56 | Process.exit(1);
57 | };
58 | };
59 |
60 | type pageAppArtifactsSuffix =
61 | | NoSuffix
62 | | UnixTimestamp;
63 |
64 | let initializeAndBuildPages =
65 | (
66 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType,
67 | ~logLevel,
68 | ~buildWorkersCount,
69 | ~pages: array(array(PageBuilder.page)),
70 | ~outputDir,
71 | ~melangeOutputDir,
72 | ~globalEnvValues,
73 | ~pageAppArtifactsSuffix,
74 | ~bundlerMode: Bundler.mode,
75 | ) => {
76 | let () = checkDuplicatedPagePaths(pages);
77 |
78 | let logger = Log.makeLogger(logLevel);
79 |
80 | let pages =
81 | switch (Bundler.bundler, bundlerMode) {
82 | | (Esbuild, Watch) =>
83 | pages->Js.Array2.map(pages =>
84 | pages->Js.Array2.map(page =>
85 | {
86 | ...page,
87 | // Add a script to implement live reloading with esbuild
88 | // https://esbuild.github.io/api/#live-reload
89 | headScripts:
90 | Js.Array2.concat(
91 | [|Esbuild.subscribeToRebuildEventScript|],
92 | page.headScripts,
93 | ),
94 | }
95 | )
96 | )
97 | | _ => pages
98 | };
99 |
100 | let renderedPages =
101 | BuildPageWorkerHelpers.buildPagesWithWorkers(
102 | ~pageAppArtifactsType,
103 | ~buildWorkersCount,
104 | ~pages,
105 | ~outputDir,
106 | ~melangeOutputDir,
107 | ~logger,
108 | ~globalEnvValues,
109 | ~exitOnPageBuildError=true,
110 | ~pageAppArtifactsSuffix=
111 | switch (pageAppArtifactsSuffix) {
112 | | NoSuffix => ""
113 | | UnixTimestamp =>
114 | "_" ++ Js.Date.make()->Js.Date.valueOf->Belt.Float.toString
115 | },
116 | );
117 |
118 | (logger, pages, renderedPages);
119 | };
120 |
121 | let build =
122 | (
123 | ~pages: array(array(PageBuilder.page)),
124 | ~globalEnvValues: array((string, string))=[||],
125 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType=Reason,
126 | ~pageAppArtifactsSuffix: pageAppArtifactsSuffix=UnixTimestamp,
127 | ~projectRootDir: string,
128 | ~outputDir: string,
129 | ~melangeOutputDir: option(string)=?,
130 | ~compileCommand: option(string)=?,
131 | ~logLevel: Log.level,
132 | ~buildWorkersCount: option(int)=?,
133 | ~webpackMode: Webpack.Mode.t=Production,
134 | ~webpackMinimizer: Webpack.Minimizer.t=Terser,
135 | ~webpackBundleAnalyzerMode:
136 | option(Webpack.WebpackBundleAnalyzerPlugin.Mode.t)=None,
137 | ~esbuildLogLevel: option(Esbuild.LogLevel.t)=?,
138 | ~esbuildLogOverride: option(Js.Dict.t(Esbuild.LogLevel.t))=?,
139 | (),
140 | )
141 | : Js.Promise.t(unit) => {
142 | let (logger, _pages, renderedPages) =
143 | initializeAndBuildPages(
144 | ~pageAppArtifactsType,
145 | ~logLevel,
146 | ~buildWorkersCount,
147 | ~pages,
148 | ~outputDir,
149 | ~melangeOutputDir,
150 | ~globalEnvValues,
151 | ~pageAppArtifactsSuffix,
152 | ~bundlerMode=Build,
153 | );
154 |
155 | renderedPages->Promise.flatMap(renderedPages => {
156 | let () =
157 | switch (pageAppArtifactsType, compileCommand) {
158 | | (Reason, None) =>
159 | Js.Console.error(
160 | "[Commands.build] Error: missing compileCommand param for Reason artifacts",
161 | );
162 | Process.exit(1);
163 | | (Reason, Some(compileCommand)) =>
164 | compileRescript(~compileCommand, ~logger)
165 | | (Js, _) => ()
166 | };
167 |
168 | switch (Bundler.bundler) {
169 | | Esbuild =>
170 | Esbuild.build(
171 | ~outputDir,
172 | ~projectRootDir,
173 | ~globalEnvValues,
174 | ~renderedPages,
175 | ~logLevel=?esbuildLogLevel,
176 | ~logOverride=?esbuildLogOverride,
177 | (),
178 | )
179 | | Webpack =>
180 | Webpack.build(
181 | ~webpackMode,
182 | ~outputDir,
183 | ~logger,
184 | ~webpackBundleAnalyzerMode,
185 | ~webpackMinimizer,
186 | ~globalEnvValues,
187 | ~renderedPages,
188 | )
189 | };
190 | });
191 | };
192 |
193 | let start =
194 | (
195 | ~pages: array(array(PageBuilder.page)),
196 | ~globalEnvValues: array((string, string))=[||],
197 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType=Reason,
198 | ~pageAppArtifactsSuffix: pageAppArtifactsSuffix=UnixTimestamp,
199 | ~projectRootDir: string,
200 | ~outputDir: string,
201 | ~melangeOutputDir: option(string)=?,
202 | ~logLevel: Log.level,
203 | ~buildWorkersCount: option(int)=?,
204 | ~webpackMode: Webpack.Mode.t=Development,
205 | ~webpackMinimizer: Webpack.Minimizer.t=Terser,
206 | ~webpackBundleAnalyzerMode:
207 | option(Webpack.WebpackBundleAnalyzerPlugin.Mode.t)=None,
208 | ~webpackDevServerOptions: Webpack.DevServerOptions.t={
209 | listenTo:
210 | Port(9000),
211 | proxy: None,
212 | },
213 | ~esbuildLogLevel: option(Esbuild.LogLevel.t)=?,
214 | ~esbuildLogOverride: option(Js.Dict.t(Esbuild.LogLevel.t))=?,
215 | ~esbuildLogLimit: option(int)=?,
216 | ~esbuildMainServerPort: int=8010,
217 | ~esbuildProxyServerPort: int=8011,
218 | ~esbuildProxyRules: array(ProxyServer.ProxyRule.t)=[||],
219 | (),
220 | ) => {
221 | let (logger, pages, renderedPages) =
222 | initializeAndBuildPages(
223 | ~pageAppArtifactsType,
224 | ~logLevel,
225 | ~buildWorkersCount,
226 | ~pages,
227 | ~outputDir,
228 | ~melangeOutputDir,
229 | ~globalEnvValues,
230 | ~pageAppArtifactsSuffix,
231 | ~bundlerMode=Watch,
232 | );
233 |
234 | let startFileWatcher = (): unit =>
235 | FileWatcher.startWatcher(
236 | ~projectRootDir,
237 | ~pageAppArtifactsType,
238 | ~outputDir,
239 | ~melangeOutputDir,
240 | ~logger,
241 | ~globalEnvValues,
242 | pages,
243 | );
244 |
245 | let delayBeforeDevServerStart =
246 | switch (pageAppArtifactsType) {
247 | | Js => 100
248 | | Reason =>
249 | // A compilation most likely is still in progress after reason artifacts emitted,
250 | // starting dev server + file watcher after a little delay.
251 | 2000
252 | };
253 |
254 | renderedPages
255 | ->Promise.map(renderedPages => {
256 | Js.Global.setTimeout(
257 | () => {
258 | switch (Bundler.bundler) {
259 | | Esbuild =>
260 | Esbuild.watchAndServe(
261 | ~outputDir,
262 | ~projectRootDir,
263 | ~globalEnvValues,
264 | ~renderedPages,
265 | ~port=esbuildMainServerPort,
266 | ~logLevel=?esbuildLogLevel,
267 | ~logOverride=?esbuildLogOverride,
268 | ~logLimit=?esbuildLogLimit,
269 | (),
270 | )
271 | ->Promise.map(serveResult => {
272 | let () =
273 | ProxyServer.start(
274 | ~port=esbuildProxyServerPort,
275 | ~targetHost=serveResult.host,
276 | ~targetPort=serveResult.port,
277 | ~proxyRules=esbuildProxyRules,
278 | ~pagePaths=
279 | renderedPages->Js.Array2.map(page =>
280 | PagePath.toString(page.path)
281 | ->Utils.maybeAddSlashPrefix
282 | ->Utils.maybeAddSlashSuffix
283 | ),
284 | );
285 | let () = startFileWatcher();
286 | ();
287 | })
288 | ->ignore
289 | | Webpack =>
290 | let () =
291 | Webpack.startDevServer(
292 | ~webpackDevServerOptions,
293 | ~webpackBundleAnalyzerMode,
294 | ~webpackMode,
295 | ~logger,
296 | ~outputDir,
297 | ~webpackMinimizer,
298 | ~globalEnvValues,
299 | ~renderedPages,
300 | ~onStart=startFileWatcher,
301 | );
302 | ();
303 | }
304 | },
305 | delayBeforeDevServerStart,
306 | )
307 | ->ignore
308 | })
309 | ->ignore;
310 | };
311 |
--------------------------------------------------------------------------------
/src/bindings/Esbuild.re:
--------------------------------------------------------------------------------
1 | type esbuild;
2 |
3 | type context;
4 |
5 | type buildResult = {
6 | errors: array(Js.Json.t),
7 | warnings: array(Js.Json.t),
8 | metafile: Js.Json.t,
9 | };
10 |
11 | module Plugin = {
12 | // https://esbuild.github.io/plugins/#on-start
13 |
14 | type buildCallbacks = {
15 | onStart: (unit => unit) => unit,
16 | onEnd: (buildResult => unit) => unit,
17 | };
18 |
19 | type t = {
20 | name: string,
21 | setup: buildCallbacks => unit,
22 | };
23 |
24 | let watchModePlugin = {
25 | name: "watchPlugin",
26 | setup: buildCallbacks => {
27 | buildCallbacks.onEnd(_buildResult =>
28 | Js.log("[Esbuild] Rebuild finished!")
29 | );
30 | },
31 | };
32 | };
33 |
34 | [@module "esbuild"] external esbuild: esbuild = "default";
35 |
36 | [@bs.send]
37 | external build': (esbuild, Js.t('a)) => Promise.t(buildResult) = "build";
38 |
39 | [@send]
40 | external context: (esbuild, Js.t('a)) => Promise.t(context) = "context";
41 |
42 | [@send] external watch: (context, unit) => Promise.t(unit) = "watch";
43 |
44 | [@send] external dispose: (context, unit) => Promise.t(unit) = "dispose";
45 |
46 | // https://esbuild.github.io/api/#serve-arguments
47 | type serveOptions = {
48 | port: int,
49 | servedir: option(string),
50 | };
51 |
52 | // https://esbuild.github.io/api/#serve-return-values
53 | type serveResult = {
54 | host: string,
55 | port: int,
56 | };
57 |
58 | [@send]
59 | external serve: (context, serveOptions) => Promise.t(serveResult) = "serve";
60 |
61 | module HtmlPlugin = {
62 | // https://github.com/craftamap/esbuild-plugin-html/blob/b74debfe7f089a4f073f5a0cf9bbdb2e59370a7c/src/index.ts#L8
63 | type options = {files: array(htmlFileConfiguration)}
64 | and htmlFileConfiguration = {
65 | filename: string,
66 | entryPoints: array(string),
67 | htmlTemplate: string,
68 | scriptLoading: string,
69 | };
70 |
71 | [@bs.module "@craftamap/esbuild-plugin-html"]
72 | external make: (. options) => Plugin.t = "htmlPlugin";
73 | };
74 |
75 | module LogLevel = {
76 | // https://esbuild.github.io/api/#log-level
77 | type t =
78 | | Silent
79 | | Error
80 | | Warning
81 | | Info
82 | | Debug;
83 |
84 | let toString = (t: t) =>
85 | switch (t) {
86 | | Silent => "silent"
87 | | Error => "error"
88 | | Warning => "warning"
89 | | Info => "info"
90 | | Debug => "debug"
91 | };
92 | };
93 |
94 | let makeConfig =
95 | (
96 | ~mode: Bundler.mode,
97 | ~outputDir: string,
98 | ~projectRootDir: string,
99 | ~globalEnvValues: array((string, string)),
100 | ~renderedPages: array(RenderedPage.t),
101 | ~logOverride: Js.Dict.t(LogLevel.t),
102 | ~logLevel: LogLevel.t,
103 | ~logLimit: int,
104 | ) =>
105 | // https://esbuild.github.io/api/
106 | {
107 | "entryPoints": renderedPages->Js.Array2.map(page => page.entryPath),
108 | "entryNames": Bundler.assetsDirname ++ "/" ++ "js/[dir]/[name]-[hash]",
109 | "chunkNames": Bundler.assetsDirname ++ "/" ++ "js/_chunks/[name]-[hash]",
110 | "assetNames": Bundler.assetsDirname ++ "/" ++ "[name]-[hash]",
111 | "outdir": Bundler.getOutputDir(~outputDir),
112 | "publicPath": Bundler.assetPrefix,
113 | "format": "esm",
114 | "bundle": true,
115 | "minify": {
116 | switch (mode) {
117 | | Build => true
118 | | Watch => false
119 | };
120 | },
121 | "metafile": true,
122 | "splitting": true,
123 | "treeShaking": true,
124 | "logLimit": logLimit,
125 | "logLevel": logLevel->LogLevel.toString,
126 | "logOverride": {
127 | let logOverride: Js.Dict.t(string) =
128 | logOverride
129 | ->Js.Dict.entries
130 | ->Js.Array2.map(((error, logLevel)) =>
131 | (error, logLevel->LogLevel.toString)
132 | )
133 | ->Js.Dict.fromArray;
134 | logOverride;
135 | },
136 | "define": Bundler.getGlobalEnvValuesDict(globalEnvValues),
137 | "loader": {
138 | Bundler.assetFileExtensionsWithoutCss
139 | ->Js.Array2.map(ext => {("." ++ ext, "file")})
140 | ->Js.Dict.fromArray;
141 | },
142 | "plugins": {
143 | let htmlPluginFiles =
144 | renderedPages->Js.Array2.map(renderedPage => {
145 | let pagePath = renderedPage.path->PagePath.toString;
146 |
147 | // entryPoint must be relative path to the root of user's project
148 | let entryPathRelativeToProjectRoot =
149 | Path.relative(~from=projectRootDir, ~to_=renderedPage.entryPath);
150 |
151 | {
152 | // filename field, which if actually a path will be relative to "outdir".
153 | HtmlPlugin.filename: pagePath ++ "/index.html",
154 | entryPoints: [|entryPathRelativeToProjectRoot|],
155 | htmlTemplate: renderedPage.htmlTemplatePath,
156 | scriptLoading: "module",
157 | };
158 | });
159 |
160 | let htmlPlugin = HtmlPlugin.make(. {files: htmlPluginFiles});
161 |
162 | switch (mode) {
163 | | Build => [|htmlPlugin|]
164 | | Watch => [|htmlPlugin, Plugin.watchModePlugin|]
165 | };
166 | },
167 | };
168 |
169 | let build =
170 | (
171 | ~outputDir: string,
172 | ~projectRootDir: string,
173 | ~globalEnvValues: array((string, string)),
174 | ~renderedPages: array(RenderedPage.t),
175 | ~logLevel: LogLevel.t=Warning,
176 | ~logOverride: Js.Dict.t(LogLevel.t)=Js.Dict.empty(),
177 | (),
178 | )
179 | : Js.Promise.t(unit) => {
180 | Js.log("[Esbuild] Bundling...");
181 |
182 | let config =
183 | makeConfig(
184 | ~mode=Build,
185 | ~outputDir,
186 | ~projectRootDir,
187 | ~globalEnvValues,
188 | ~renderedPages,
189 | ~logLevel,
190 | ~logOverride,
191 | ~logLimit=10,
192 | );
193 |
194 | let startTime = Performance.now();
195 |
196 | esbuild
197 | ->build'(config)
198 | ->Promise.map(_buildResult => {
199 | // let json =
200 | // Js.Json.stringifyAny(_buildResult.metafile)
201 | // ->Belt.Option.getWithDefault("");
202 | // Fs.writeFileSync(~path=Path.join2(outputDir, "meta.json"), ~data=json);
203 | Js.log2(
204 | "[Esbuild] Success! Duration:",
205 | Performance.durationSinceStartTime(~startTime),
206 | )
207 | })
208 | ->Promise.catch(error => {
209 | Js.Console.error2(
210 | "[Esbuild] Build failed! Promise.catch:",
211 | error->Util.inspect,
212 | );
213 | Process.exit(1);
214 | });
215 | };
216 |
217 | let watchAndServe =
218 | (
219 | ~outputDir,
220 | ~projectRootDir: string,
221 | ~globalEnvValues: array((string, string)),
222 | ~renderedPages: array(RenderedPage.t),
223 | ~port: int,
224 | ~logLevel: LogLevel.t=Warning,
225 | ~logOverride: Js.Dict.t(LogLevel.t)=Js.Dict.empty(),
226 | ~logLimit=10,
227 | (),
228 | )
229 | : Promise.t(serveResult) => {
230 | let config =
231 | makeConfig(
232 | ~mode=Watch,
233 | ~outputDir,
234 | ~projectRootDir,
235 | ~globalEnvValues,
236 | ~renderedPages,
237 | ~logLevel,
238 | ~logOverride,
239 | ~logLimit,
240 | );
241 | Js.log("[Esbuild] Starting esbuild...");
242 | let watchDurationLabel = "[Esbuild] Watch mode started! Duration";
243 | let serveDurationLabel = "[Esbuild] Serve mode started! Duration";
244 | Js.Console.timeStart(watchDurationLabel);
245 |
246 | let contextPromise = esbuild->context(config);
247 |
248 | GracefulShutdown.addTask(() => {
249 | Js.log("[Esbuild] Stopping esbuild...");
250 |
251 | Js.Global.setTimeout(
252 | () => {
253 | Js.log("[Esbuild] Failed to gracefully shutdown.");
254 | Process.exit(1);
255 | },
256 | GracefulShutdown.gracefulShutdownTimeout,
257 | )
258 | ->ignore;
259 |
260 | contextPromise
261 | ->Promise.flatMap(context => context->dispose())
262 | ->Promise.map(() => Js.log("[Esbuild] Stopped successfully"));
263 | });
264 |
265 | contextPromise
266 | ->Promise.flatMap(context => context->watch())
267 | ->Promise.map(() => Js.Console.timeEnd(watchDurationLabel))
268 | ->Promise.catch(error => {
269 | Js.Console.error2("[Esbuild] Failed to start watch mode:", error);
270 | Process.exit(1);
271 | })
272 | ->Promise.flatMap(() => {
273 | Js.Console.timeStart(serveDurationLabel);
274 | contextPromise->Promise.flatMap(context =>
275 | context->serve({port, servedir: Some(config##outdir)})
276 | );
277 | })
278 | ->Promise.map(serveResult => {
279 | Js.Console.timeEnd(serveDurationLabel);
280 | serveResult;
281 | })
282 | ->Promise.catch(error => {
283 | Js.Console.error2("[Esbuild] Failed to start serve mode:", error);
284 | Process.exit(1);
285 | });
286 | };
287 |
288 | let subscribeToRebuildEventScript = "new EventSource('/esbuild').addEventListener('change', () => location.reload());";
289 |
290 | type import = {
291 | path: string,
292 | kind: string,
293 | original: string,
294 | };
295 |
296 | type input = {
297 | bytes: int,
298 | imports: array(import),
299 | format: string,
300 | };
301 |
302 | type metafile = {inputs: Js.Dict.t(input)};
303 |
304 | external unsafeJsonToMetafile: Js.Json.t => metafile = "%identity";
305 |
306 | let getModuleDependencies =
307 | (~exitOnError, ~projectRootDir: string, ~modulePath: string)
308 | : Js.Promise.t(array(string)) => {
309 | let config = {
310 | "entryPoints": [|modulePath|],
311 | // Outdir technically isn't used because "write" is false, but esbuild has complaints without it
312 | "outdir": "unused",
313 | "write": false,
314 | "format": "esm",
315 | "bundle": true,
316 | "minify": false,
317 | "metafile": true,
318 | "splitting": false,
319 | "treeShaking": false,
320 | "logLevel": LogLevel.toString(Silent),
321 | "loader": {
322 | Bundler.assetFileExtensionsWithoutCss
323 | ->Js.Array2.map(ext => {("." ++ ext, "file")})
324 | ->Js.Dict.fromArray;
325 | },
326 | };
327 |
328 | esbuild
329 | ->build'(config)
330 | ->Promise.map(buildResult => {
331 | // let json =
332 | // Js.Json.stringifyAny(buildResult.metafile)
333 | // ->Belt.Option.getWithDefault("");
334 | // Fs.writeFileSync(~path="meta.json", ~data=json);
335 | let metafile = buildResult.metafile->unsafeJsonToMetafile;
336 | let dependencies =
337 | metafile.inputs
338 | ->Js.Dict.entries
339 | ->Belt.Array.keepMap(((dependencyPath, _)) => {
340 | // Filter out dependencies from node_modules
341 | switch (dependencyPath->Js.String2.indexOf("node_modules")) {
342 | | (-1) => Some(Path.join2(projectRootDir, dependencyPath))
343 | | _ => None
344 | }
345 | });
346 | dependencies;
347 | })
348 | ->Promise.catch(error =>
349 | if (exitOnError) {
350 | Js.Console.error2(
351 | "[Esbuild] Get module dependencies failed! Stopping process. Promise.catch:",
352 | error->Util.inspect,
353 | );
354 | Process.exit(1);
355 | } else {
356 | Js.Console.error2(
357 | "[Esbuild] Get module dependencies failed! Returning empty array. Promise.catch:",
358 | error->Util.inspect,
359 | );
360 | Promise.resolve([||]);
361 | }
362 | );
363 | };
364 |
--------------------------------------------------------------------------------
/src/ProxyServer.re:
--------------------------------------------------------------------------------
1 | module Server = {
2 | type t;
3 | [@send] external listen: (t, int, unit => unit) => unit = "listen";
4 |
5 | [@send] external close: (t, unit => unit) => unit = "close";
6 |
7 | [@send]
8 | external closeAllConnections: (t, unit) => unit = "closeAllConnections";
9 |
10 | [@set] external setKeepAliveTimeoutMs: (t, int) => unit = "keepAliveTimeout";
11 | };
12 |
13 | module ClientRequest = {
14 | // https://nodejs.org/api/http.html#class-httpclientrequest
15 | type t;
16 |
17 | type error;
18 |
19 | [@send] external on: (t, string, error => unit) => unit = "on";
20 | };
21 |
22 | // https://nodejs.org/api/http.html#httprequestoptions-callback
23 | type nodeRequestOptions = {
24 | hostname: option(string),
25 | port: option(int),
26 | path: string,
27 | method: string,
28 | headers: Js.Dict.t(string),
29 | // Cannot be used if one of host or port is specified, as those specify a TCP Socket.
30 | socketPath: option(string),
31 | };
32 |
33 | module ServerResponse = {
34 | type t;
35 | [@get] external statusCode: t => int = "statusCode";
36 |
37 | [@send]
38 | external writeHead:
39 | (t, ~statusCode: int, ~headers: option(Js.Dict.t(string))) => t =
40 | "writeHead";
41 |
42 | [@send] external end_: (t, string) => unit = "end";
43 | };
44 |
45 | module IncommingMessage = {
46 | type t;
47 | [@get] external statusCode: t => int = "statusCode";
48 | [@get] external url: t => string = "url";
49 | [@get] external method: t => string = "method";
50 | [@get] external headers: t => Js.Dict.t(string) = "headers";
51 |
52 | type pipeOptions = {
53 | [@bs.as "end"]
54 | end_: bool,
55 | };
56 |
57 | [@send]
58 | external pipeToServerResponse: (t, ServerResponse.t, pipeOptions) => unit =
59 | "pipe";
60 | [@send]
61 | external pipeToClientRequest: (t, ClientRequest.t, pipeOptions) => unit =
62 | "pipe";
63 | };
64 |
65 | [@bs.module "node:http"]
66 | external nodeCreateServer:
67 | ((IncommingMessage.t, ServerResponse.t) => unit) => Server.t =
68 | "createServer";
69 |
70 | [@bs.module "node:http"]
71 | external nodeRequest:
72 | (nodeRequestOptions, IncommingMessage.t => unit) => ClientRequest.t =
73 | "request";
74 |
75 | module Url = {
76 | type t;
77 | [@bs.new] [@bs.scope "global"]
78 | external makeExn: (string, ~base: option(string)) => t = "URL";
79 | [@bs.get] external hash: t => string = "hash";
80 | [@bs.get] external host: t => string = "host";
81 | [@bs.get] external hostname: t => string = "hostname";
82 | [@bs.get] external href: t => string = "href";
83 | [@bs.get] external origin: t => string = "origin";
84 | [@bs.get] external protocol: t => string = "protocol";
85 | [@bs.get] external pathname: t => string = "pathname";
86 | // Yes, port parsed as string
87 | // https://nodejs.org/api/url.html#urlport
88 | [@bs.get] external port: t => string = "port";
89 | [@bs.get] external search: t => string = "search";
90 | [@bs.get] external searchParams: t => Js.Dict.t(string) = "searchParams";
91 |
92 | let make = (path, ~base) =>
93 | switch (makeExn(path, ~base)) {
94 | | url => Some(url)
95 | | exception _ => None
96 | };
97 | };
98 |
99 | module ProxyRule = {
100 | type target =
101 | | Url(string)
102 | | UnixSocket(string);
103 |
104 | type pathRewrite = {
105 | pathRewriteFrom: string,
106 | pathRewriteTo: string,
107 | };
108 |
109 | type proxyTo = {
110 | target,
111 | pathRewrite: option(pathRewrite),
112 | };
113 |
114 | type t = {
115 | fromPath: string,
116 | proxyTo,
117 | };
118 | };
119 |
120 | module ValidProxyRule = {
121 | type target =
122 | | Url(Url.t)
123 | | UnixSocket(string);
124 |
125 | type proxyTo = {
126 | target,
127 | pathRewrite: option(ProxyRule.pathRewrite),
128 | };
129 |
130 | type t = {
131 | fromPath: string,
132 | proxyTo,
133 | };
134 |
135 | let fromProxyRule = (proxyRule: ProxyRule.t): t => {
136 | let target =
137 | switch (proxyRule.proxyTo.target) {
138 | | UnixSocket(path) => UnixSocket(path)
139 | | Url(str) =>
140 | let url = Url.make(str, ~base=None);
141 | switch (url) {
142 | | None =>
143 | Js.Console.error2(
144 | "[Dev server] Error, failed to parse URL string:",
145 | str,
146 | );
147 | Process.exit(1);
148 | | Some(url) => Url(url)
149 | };
150 | };
151 |
152 | {
153 | fromPath: proxyRule.fromPath,
154 | proxyTo: {
155 | target,
156 | pathRewrite: proxyRule.proxyTo.pathRewrite,
157 | },
158 | };
159 | };
160 | };
161 |
162 | let sortPathsBySegmentCount = (a, b) => {
163 | // Sort paths to make sure that more specific rules are matched first.
164 | let countSegments = s =>
165 | s
166 | ->Js.String2.split("/")
167 | ->Js.Array2.filter(s => s != "")
168 | ->Js.Array2.length;
169 |
170 | let segCount1 = countSegments(a);
171 | let segCount2 = countSegments(b);
172 |
173 | if (segCount1 == segCount2) {
174 | 0;
175 | } else if (segCount1 < segCount2) {
176 | 1;
177 | } else {
178 | (-1);
179 | };
180 | };
181 |
182 | let isPageWithDynamicPathSegmentRequested =
183 | (reqPath: string, pagePath: string) => {
184 | let makeSegments = path =>
185 | path
186 | ->Utils.maybeAddSlashPrefix
187 | ->Utils.maybeAddSlashSuffix
188 | ->Js.String2.split("/")
189 | ->Js.Array2.filter(s => s != "")
190 | ->Belt.List.fromArray;
191 |
192 | let reqPathSegments = reqPath->makeSegments;
193 | let pagePathSegments = pagePath->makeSegments;
194 |
195 | let rec isMatch = (reqPathSegments, pagePathSegments) => {
196 | switch (reqPathSegments, pagePathSegments) {
197 | | ([], []) => true
198 | | ([], _)
199 | | (_, []) => false
200 | | ([reqSegment, ...reqTail], [pageSegment, ...pageTail]) =>
201 | pageSegment == PagePath.dynamicSegment || reqSegment == pageSegment
202 | ? isMatch(reqTail, pageTail) : false
203 | };
204 | };
205 | isMatch(reqPathSegments, pagePathSegments);
206 | };
207 |
208 | let start =
209 | (
210 | ~port: int,
211 | ~targetHost: string,
212 | ~targetPort: int,
213 | ~proxyRules: array(ProxyRule.t),
214 | ~pagePaths: array(string),
215 | ) => {
216 | let pagePathsWithDynamicSegments =
217 | pagePaths
218 | ->Js.Array2.filter(path =>
219 | path->Js.String2.includes(PagePath.dynamicSegment)
220 | )
221 | ->Js.Array2.sortInPlaceWith(sortPathsBySegmentCount);
222 |
223 | let proxyRules =
224 | proxyRules
225 | ->Js.Array2.map(rule => ValidProxyRule.fromProxyRule(rule))
226 | ->Js.Array2.sortInPlaceWith((a, b) =>
227 | sortPathsBySegmentCount(a.fromPath, b.fromPath)
228 | );
229 |
230 | let server =
231 | nodeCreateServer((req, res) => {
232 | let reqUrl = req->IncommingMessage.url;
233 | let reqHeaders = req->IncommingMessage.headers;
234 | let reqHost =
235 | reqHeaders->Js.Dict.get("host")->Belt.Option.getWithDefault("");
236 | let urlBase = "http://" ++ reqHost;
237 | let url = Url.make(reqUrl, ~base=Some(urlBase));
238 |
239 | let (reqPath, reqQueryString) =
240 | switch (url) {
241 | | None => (reqUrl, "")
242 | | Some(url) => (url->Url.pathname, url->Url.search)
243 | };
244 |
245 | let targetReqOptions: nodeRequestOptions = {
246 | let defaultTarget = {
247 | hostname: Some(targetHost),
248 | port: Some(targetPort),
249 | path: req->IncommingMessage.url,
250 | method: req->IncommingMessage.method,
251 | headers: req->IncommingMessage.headers,
252 | socketPath: None,
253 | };
254 |
255 | let reqPathNormalized =
256 | reqPath->Utils.maybeAddSlashPrefix->Utils.maybeAddSlashSuffix;
257 |
258 | let exactPagePathRelatedToRequestedPath = {
259 | pagePaths->Js.Array2.find(pagePath => pagePath == reqPathNormalized);
260 | };
261 |
262 | switch (exactPagePathRelatedToRequestedPath) {
263 | | Some(_) =>
264 | // esbuild server redirects request to a path with trailing slash if a path without trailing slash requested.
265 | // To avoid this redirect we add trailing slash.
266 | {
267 | ...defaultTarget,
268 | path: defaultTarget.path->Utils.maybeAddSlashSuffix,
269 | }
270 | | None =>
271 | let relatedPagePathWithDynamicSegment =
272 | pagePathsWithDynamicSegments->Js.Array2.find(pagePath =>
273 | isPageWithDynamicPathSegmentRequested(reqPath, pagePath)
274 | );
275 | switch (relatedPagePathWithDynamicSegment) {
276 | | Some(relatedPagePathWithDynamicSegment) =>
277 | Js.log2(
278 | "[Dev server] Page with dynamic segment requested, path rewritten to:",
279 | relatedPagePathWithDynamicSegment,
280 | );
281 | {
282 | hostname: Some(targetHost),
283 | port: Some(targetPort),
284 | path: relatedPagePathWithDynamicSegment ++ reqQueryString,
285 | method: req->IncommingMessage.method,
286 | headers: req->IncommingMessage.headers,
287 | socketPath: None,
288 | };
289 | | None =>
290 | let matchedProxyRule =
291 | proxyRules->Js.Array2.find(rule =>
292 | reqPath->Js.String2.startsWith(rule.fromPath)
293 | );
294 | switch (matchedProxyRule) {
295 | | None =>
296 | // Technically, this is some kind of error:
297 | // User requested a path that doesn't have related page and proxy rule for this request also not exist.
298 | defaultTarget
299 | | Some({fromPath, proxyTo: {target, pathRewrite}}) =>
300 | let (path, isPathRewritten) =
301 | switch (pathRewrite) {
302 | | None => (reqPath, false)
303 | | Some({pathRewriteFrom, pathRewriteTo}) =>
304 | let newPath =
305 | reqPath->Js.String2.replace(
306 | pathRewriteFrom,
307 | pathRewriteTo,
308 | );
309 | (newPath, true);
310 | };
311 |
312 | switch (isPathRewritten) {
313 | | false => Js.log2("[Dev server] Proxy rule matched:", fromPath)
314 | | true =>
315 | Js.log(
316 | {j|[Dev server] Proxy rule matched: $(fromPath), path rewritten to: $(path)|j},
317 | )
318 | };
319 |
320 | switch (target) {
321 | | UnixSocket(socketPath) => {
322 | hostname: None,
323 | port: None,
324 | socketPath: Some(socketPath),
325 | path: path ++ reqQueryString,
326 | method: req->IncommingMessage.method,
327 | headers: req->IncommingMessage.headers,
328 | }
329 | | Url(url) => {
330 | hostname: Some(url->Url.hostname),
331 | port:
332 | Some(
333 | url
334 | ->Url.port
335 | ->Belt.Int.fromString
336 | ->Belt.Option.getWithDefault(80),
337 | ),
338 | socketPath: None,
339 | path: path ++ reqQueryString,
340 | method: req->IncommingMessage.method,
341 | headers: req->IncommingMessage.headers,
342 | }
343 | };
344 | };
345 | };
346 | };
347 | };
348 |
349 | let proxyReq =
350 | nodeRequest(
351 | targetReqOptions,
352 | targetRes => {
353 | res
354 | ->ServerResponse.writeHead(
355 | ~statusCode=targetRes->IncommingMessage.statusCode,
356 | ~headers=Some(targetRes->IncommingMessage.headers),
357 | )
358 | ->ignore;
359 | targetRes->IncommingMessage.pipeToServerResponse(
360 | res,
361 | {end_: true},
362 | );
363 | },
364 | );
365 |
366 | proxyReq->ClientRequest.on("error", error => {
367 | Js.Console.error2("[Dev server] Error with proxy request:", error);
368 | res
369 | ->ServerResponse.writeHead(~statusCode=404, ~headers=None)
370 | ->ServerResponse.end_("[Dev server] Internal server error");
371 | });
372 |
373 | req->IncommingMessage.pipeToClientRequest(proxyReq, {end_: true});
374 | });
375 |
376 | server->Server.setKeepAliveTimeoutMs(2000);
377 |
378 | let startServer = () =>
379 | server->Server.listen(port, () =>
380 | Js.log("[Dev server] Listening on port " ++ string_of_int(port))
381 | );
382 |
383 | switch (startServer()) {
384 | | () => ()
385 | | exception exn =>
386 | Js.Console.error2("[Dev server] Failed to start, error:", exn);
387 | Process.exit(1);
388 | };
389 |
390 | GracefulShutdown.addTask(() => {
391 | Js.log("[Dev server] Stopping dev server...");
392 |
393 | Promise.make((~resolve, ~reject as _reject) => {
394 | let unit = ();
395 |
396 | server->Server.close(() => {
397 | Js.log("[Dev server] Stopped successfully");
398 | resolve(. unit);
399 | });
400 |
401 | server->Server.closeAllConnections();
402 |
403 | Js.Global.setTimeout(
404 | () => {
405 | Js.Console.error("[Dev server] Failed to gracefully shutdown.");
406 | Process.exit(1);
407 | },
408 | GracefulShutdown.gracefulShutdownTimeout,
409 | )
410 | ->ignore;
411 | });
412 | });
413 |
414 | ();
415 | };
416 |
--------------------------------------------------------------------------------
/src/FileWatcher.re:
--------------------------------------------------------------------------------
1 | let uniqueStringArray = (array: array(string)) =>
2 | Set.fromArray(array)->Set.toArray;
3 |
4 | let uniqueArray = (array: array('a), ~getId: 'a => string) => {
5 | let items = array->Js.Array2.map(v => (v->getId, v));
6 | items->Js.Dict.fromArray->Js.Dict.values;
7 | };
8 |
9 | let makeUniquePageArray = (pages: array(PageBuilder.page)) => {
10 | pages->uniqueArray(~getId=page => PagePath.toString(page.path));
11 | };
12 |
13 | let showPages = (pages: array(PageBuilder.page)) => {
14 | pages->Js.Array2.map(page => {
15 | Log.makeMinimalPrintablePageObj(
16 | ~pagePath=page.path,
17 | ~pageModulePath=page.modulePath,
18 | )
19 | });
20 | };
21 |
22 | // To make a watcher work properly we need to:
23 | // 1. Watch for the changes in a root module (page module).
24 | // 2. Watch for the changes in all dependencies of a root module (except node modules).
25 | // 3. Watch for the changes in wrapper components and handle them as dependencies of a root module.
26 | // 4. Watch for the changes in headCssFilepaths.
27 |
28 | // After a module changes we should rebuild a page and refresh dependency dicts to remove the stale ones.
29 |
30 | // The watcher logic looks like this:
31 | // We detect change in some file.
32 | // If the change is in the exact page module -> simply get pages from modulePathToPagesDict and rebuild them.
33 | // If the change is in some dependency -> get root modules from a dependency -> get pages from root modules.
34 | // If the change is in a head CSS file -> get pages that use this css file and rebuild them.
35 |
36 | let startWatcher =
37 | (
38 | ~pageAppArtifactsType: PageBuilder.pageAppArtifactsType,
39 | ~projectRootDir: string,
40 | ~outputDir: string,
41 | ~melangeOutputDir: option(string),
42 | ~logger: Log.logger,
43 | ~globalEnvValues: array((string, string)),
44 | ~buildWorkersCount: option(int)=?,
45 | pages: array(array(PageBuilder.page)),
46 | )
47 | : unit => {
48 | let watcher = Chokidar.chokidar->Chokidar.watchFiles([||]);
49 |
50 | let pages = Array.flat1(pages);
51 |
52 | let durationLabel = "[Watcher] Watching for file changes... Startup duration";
53 | Js.Console.timeStart(durationLabel);
54 |
55 | // Dependency is a some import in page's main module (page.modulePath).
56 | // Multiple pages can depend on the same dependency.
57 | // The dict below maps dependency path to the array of page module paths.
58 | let dependencyToPageModulesDict = Js.Dict.empty();
59 |
60 | let updateDependencyToPageModulesDict = (~dependency, ~pageModulePath) => {
61 | switch (dependencyToPageModulesDict->Js.Dict.get(dependency)) {
62 | | None =>
63 | dependencyToPageModulesDict->Js.Dict.set(
64 | dependency,
65 | ([|pageModulePath|], Set.fromArray([|pageModulePath|])),
66 | )
67 | | Some((pageModulePaths, pageModulePathsSet)) =>
68 | switch (pageModulePathsSet->Set.has(pageModulePath)) {
69 | | true => ()
70 | | _false =>
71 | pageModulePaths->Js.Array2.push(pageModulePath)->ignore;
72 | dependencyToPageModulesDict->Js.Dict.set(
73 | dependency,
74 | (pageModulePaths, pageModulePathsSet->Set.add(pageModulePath)),
75 | );
76 | }
77 | };
78 | };
79 |
80 | let modulePathToPagesDict = Js.Dict.empty();
81 | let headCssFileToPagesDict = Js.Dict.empty();
82 | let pageWrapperModulePaths = [||];
83 |
84 | pages->Js.Array2.forEach(page => {
85 | // Multiple pages can use the same page module. The common case is localized pages.
86 | // We get modulePath -> array(pages) dict here.
87 | // Fill modulePathToPagesDict
88 | switch (modulePathToPagesDict->Js.Dict.get(page.modulePath)) {
89 | | None => modulePathToPagesDict->Js.Dict.set(page.modulePath, [|page|])
90 | | Some(pages) => pages->Js.Array2.push(page)->ignore
91 | };
92 |
93 | // Fill headCssFileToPagesDict
94 | page.headCssFilepaths
95 | ->Js.Array2.forEach(headCssFile => {
96 | switch (headCssFileToPagesDict->Js.Dict.get(headCssFile)) {
97 | | None =>
98 | headCssFileToPagesDict->Js.Dict.set(
99 | headCssFile,
100 | ([|page|], Set.fromArray([|page.path->PagePath.toString|])),
101 | )
102 | | Some((pages, pagePathsSet)) =>
103 | switch (pagePathsSet->Set.has(page.path->PagePath.toString)) {
104 | | true => ()
105 | | _false =>
106 | pages->Js.Array2.push(page)->ignore;
107 | headCssFileToPagesDict->Js.Dict.set(
108 | headCssFile,
109 | (pages, pagePathsSet->Set.add(page.path->PagePath.toString)),
110 | );
111 | }
112 | }
113 | });
114 |
115 | // We handle pageWrapper module as a dependency of the page's module.
116 | // If pageWrapper module changes we check what page modules depend on it and rebuild them.
117 | // Fill pageWrapperModulePaths
118 | switch (page.pageWrapper) {
119 | | None => ()
120 | | Some(wrapper) =>
121 | // Page wrapper can import other modules and have dependencies as well.
122 | // This should be also handled.
123 | pageWrapperModulePaths->Js.Array2.push(wrapper.modulePath)->ignore;
124 | updateDependencyToPageModulesDict(
125 | ~dependency=wrapper.modulePath,
126 | ~pageModulePath=page.modulePath,
127 | );
128 | };
129 | });
130 |
131 | let pageModulePaths = modulePathToPagesDict->Js.Dict.keys;
132 |
133 | let pageModulesAndTheirDependencies =
134 | pageModulePaths->Js.Array2.map(pageModulePath => {
135 | Esbuild.getModuleDependencies(
136 | // Initial watcher start and getModuleDependencies must perform successfully.
137 | ~exitOnError=true,
138 | ~projectRootDir,
139 | ~modulePath=pageModulePath,
140 | )
141 | ->Promise.map(pageDependencies => (pageModulePath, pageDependencies))
142 | });
143 |
144 | let _: Js.Promise.t(unit) =
145 | pageModulesAndTheirDependencies
146 | ->Promise.all
147 | ->Promise.map(pageModulesAndTheirDependencies => {
148 | // Fill dependencyToPageModulesDict
149 | pageModulesAndTheirDependencies->Js.Array2.forEach(
150 | ((pageModulePath, pageDependencies)) => {
151 | pageDependencies->Js.Array2.forEach(dependency =>
152 | updateDependencyToPageModulesDict(~dependency, ~pageModulePath)
153 | )
154 | });
155 |
156 | let allDependencies = {
157 | let dependencies = [||];
158 |
159 | headCssFileToPagesDict
160 | ->Js.Dict.keys
161 | ->Js.Array2.forEach(headCssPath =>
162 | dependencies->Js.Array2.push(headCssPath)->ignore
163 | );
164 |
165 | dependencyToPageModulesDict
166 | ->Js.Dict.keys
167 | ->Js.Array2.forEach(dependencyPath =>
168 | dependencies->Js.Array2.push(dependencyPath)->ignore
169 | );
170 |
171 | pageModulePaths->Js.Array2.forEach(pageModulePath =>
172 | dependencies->Js.Array2.push(pageModulePath)->ignore
173 | );
174 |
175 | pageWrapperModulePaths->Js.Array2.forEach(pageWrapperModulePath =>
176 | dependencies->Js.Array2.push(pageWrapperModulePath)->ignore
177 | );
178 |
179 | dependencies;
180 | };
181 |
182 | watcher->Chokidar.add(allDependencies);
183 |
184 | logger.info(() => {
185 | Js.log("[Watcher] Initial dependencies collected");
186 | Js.Console.timeEnd(durationLabel);
187 | });
188 |
189 | logger.debug(() =>
190 | Js.log2(
191 | "[Watcher] Initial watcher dependencies:\n",
192 | allDependencies,
193 | )
194 | );
195 | });
196 |
197 | let rebuildQueueRef: ref(array(PageBuilder.page)) = ref([||]);
198 |
199 | let rebuildPages = (): Js.Promise.t(unit) => {
200 | switch (rebuildQueueRef^) {
201 | | [||] => Promise.resolve()
202 | | pagesToRebuild =>
203 | logger.info(() => Js.log("[Watcher] Pages rebuild triggered..."));
204 | logger.debug(() =>
205 | Js.log2(
206 | "[Watcher] Passing pages to worker to rebuild:\n",
207 | pagesToRebuild->showPages,
208 | )
209 | );
210 | BuildPageWorkerHelpers.buildPagesWithWorkers(
211 | ~pageAppArtifactsType,
212 | ~buildWorkersCount,
213 | // TODO Here we probably should group pages to rebuild by globalValues (one globalValues per worker)
214 | ~pages=[|pagesToRebuild|],
215 | ~outputDir,
216 | ~melangeOutputDir,
217 | ~logger,
218 | ~globalEnvValues,
219 | ~exitOnPageBuildError=false,
220 | ~pageAppArtifactsSuffix="",
221 | )
222 | ->Promise.flatMap(_ => {
223 | logger.debug(() =>
224 | Js.log(
225 | "[Watcher] Pages rebuild success, updating dependencies to watch...",
226 | )
227 | );
228 |
229 | let updatedPageModulesAndTheirDependencies =
230 | pagesToRebuild->Js.Array2.map(page => {
231 | let pageModulePath = page.modulePath;
232 |
233 | Esbuild.getModuleDependencies(
234 | // While compiler is working, this operation may fail.
235 | // It will return an empty array in this case, so we can ignore the error.
236 | // Maybe we should add some logic to make one more attempt to get dependencies.
237 | ~exitOnError=false,
238 | ~projectRootDir,
239 | ~modulePath=pageModulePath,
240 | )
241 | ->Promise.map(pageDependencies =>
242 | (pageModulePath, pageDependencies)
243 | );
244 | });
245 |
246 | let newDependencies =
247 | updatedPageModulesAndTheirDependencies
248 | ->Promise.all
249 | ->Promise.map(pageModulesAndTheirDependencies => {
250 | pageModulesAndTheirDependencies->Js.Array2.map(
251 | ((pageModulePath, pageDependencies)) => {
252 | logger.debug(() => {
253 | Js.log(
254 | {j|[Watcher] Dependencies of updated page module: $(pageModulePath) are: $(pageDependencies)|j},
255 | )
256 | });
257 |
258 | pageDependencies->Js.Array2.forEach(dependency =>
259 | updateDependencyToPageModulesDict(
260 | ~dependency,
261 | ~pageModulePath,
262 | )
263 | );
264 |
265 | pageDependencies;
266 | })
267 | });
268 |
269 | newDependencies->Promise.map(newDependencies => {
270 | let newDependencies = newDependencies->Array.flat1;
271 |
272 | watcher->Chokidar.add(newDependencies);
273 |
274 | logger.debug(() => {
275 | Js.log2(
276 | "[Watcher] Pages are rebuilded, dependencyToPageModulesDict:\n",
277 | dependencyToPageModulesDict,
278 | )
279 | });
280 |
281 | logger.info(() => {
282 | Js.log(
283 | "[Watcher] Pages are rebuilded, files to watch are updated",
284 | )
285 | });
286 |
287 | rebuildQueueRef := [||];
288 | });
289 | });
290 | };
291 | };
292 |
293 | let rebuildPagesDebounced =
294 | Debounce.debounce(~delayMs=1000, () => rebuildPages()->ignore);
295 |
296 | let onChangeOrUnlink = filepath => {
297 | let pagesToRebuild =
298 | switch (modulePathToPagesDict->Js.Dict.get(filepath)) {
299 | | Some(pages) =>
300 | logger.debug(() =>
301 | Js.log2("[Watcher] Exact page module changed: ", filepath)
302 | );
303 | pages;
304 | | None =>
305 | switch (dependencyToPageModulesDict->Js.Dict.get(filepath)) {
306 | | Some((pageModules, _pageModulesSet)) =>
307 | logger.debug(() => {
308 | Js.log2("[Watcher] Dependency changed: ", filepath);
309 | Js.log2(
310 | "[Watcher] Should rebuild these page modules:\n",
311 | pageModules,
312 | );
313 | });
314 |
315 | let pages =
316 | pageModules
317 | ->Belt.Array.keepMap(modulePath => {
318 | switch (modulePathToPagesDict->Js.Dict.get(modulePath)) {
319 | | Some(pages) => Some(pages)
320 | | None =>
321 | logger.debug(() =>
322 | Js.log2(
323 | "[Watcher] [Warning] The following page module is missing in dict: ",
324 | modulePath,
325 | )
326 | );
327 | None;
328 | }
329 | })
330 | ->Array.flat1;
331 |
332 | pages;
333 | | None =>
334 | switch (headCssFileToPagesDict->Js.Dict.get(filepath)) {
335 | | Some((pages, _pagePathsSet)) =>
336 | logger.debug(() =>
337 | Js.log2("[Watcher] Head CSS file changed: ", filepath)
338 | );
339 | pages;
340 | | None =>
341 | // Nothing depends on a changed file. We should remove it from watcher.
342 | watcher->Chokidar.unwatch([|filepath|]);
343 |
344 | logger.debug(() =>
345 | Js.log2(
346 | "[Watcher] [Warning] No pages depend on the file: ",
347 | filepath,
348 | )
349 | );
350 |
351 | [||];
352 | }
353 | }
354 | };
355 |
356 | let newRebuildQueue =
357 | Js.Array2.concat(pagesToRebuild, rebuildQueueRef^)->makeUniquePageArray;
358 |
359 | rebuildQueueRef := newRebuildQueue;
360 |
361 | logger.debug(() =>
362 | Js.log2(
363 | "[Watcher] Rebuild pages queue:\n",
364 | (rebuildQueueRef^)->showPages,
365 | )
366 | );
367 |
368 | rebuildPagesDebounced();
369 | };
370 |
371 | // With rescript/bucklescript, "change" event is triggered when JS file updated after compilation.
372 | // But with Melange, "unlink" event is triggered.
373 | watcher->Chokidar.onChange(filepath => {
374 | logger.debug(() => Js.log2("[Watcher] Chokidar.onChange: ", filepath));
375 | onChangeOrUnlink(filepath);
376 | });
377 |
378 | watcher->Chokidar.onUnlink(filepath => {
379 | logger.debug(() => Js.log2("[Watcher] Chokidar.onUnlink: ", filepath));
380 | onChangeOrUnlink(filepath);
381 | });
382 | };
383 |
--------------------------------------------------------------------------------
/src/Webpack.re:
--------------------------------------------------------------------------------
1 | type webpackPlugin;
2 |
3 | module NodeLoader = NodeLoader; /* Workaround bug in dune and melange: https://github.com/ocaml/dune/pull/6625 */
4 | module Crypto = Crypto; /* Workaround bug in dune and melange: https://github.com/ocaml/dune/pull/6625 */
5 |
6 | module HtmlWebpackPlugin = {
7 | [@bs.module "html-webpack-plugin"] [@bs.new]
8 | external make: Js.t('a) => webpackPlugin = "default";
9 | };
10 |
11 | module MiniCssExtractPlugin = {
12 | [@bs.module "mini-css-extract-plugin"] [@bs.new]
13 | external make: Js.t('a) => webpackPlugin = "default";
14 |
15 | [@bs.module "mini-css-extract-plugin"] [@bs.scope "default"]
16 | external loader: string = "loader";
17 | };
18 |
19 | module TerserPlugin = {
20 | type minifier;
21 | [@bs.module "terser-webpack-plugin"] [@bs.new]
22 | external make: Js.t('a) => webpackPlugin = "default";
23 | [@bs.module "terser-webpack-plugin"] [@bs.scope "default"]
24 | external swcMinify: minifier = "swcMinify";
25 | [@bs.module "terser-webpack-plugin"] [@bs.scope "default"]
26 | external esbuildMinify: minifier = "esbuildMinify";
27 | };
28 |
29 | module WebpackBundleAnalyzerPlugin = {
30 | // https://github.com/webpack-contrib/webpack-bundle-analyzer#options-for-plugin
31 |
32 | type analyzerMode = [ | `server | `static | `json | `disabled];
33 |
34 | type options = {
35 | analyzerMode,
36 | reportFilename: option(string),
37 | openAnalyzer: bool,
38 | analyzerPort: option(int),
39 | };
40 |
41 | [@bs.module "webpack-bundle-analyzer"] [@bs.new]
42 | external make': options => webpackPlugin = "BundleAnalyzerPlugin";
43 |
44 | module Mode = {
45 | // This module contains an interface that is exposed by rescript-ssg
46 | type staticModeOptions = {reportHtmlFilepath: string};
47 |
48 | type serverModeOptions = {port: int};
49 |
50 | type t =
51 | | Static(staticModeOptions)
52 | | Server(serverModeOptions);
53 |
54 | let makeOptions = (mode: t) => {
55 | switch (mode) {
56 | | Static({reportHtmlFilepath}) => {
57 | analyzerMode: `static,
58 | reportFilename: Some(reportHtmlFilepath),
59 | openAnalyzer: false,
60 | analyzerPort: None,
61 | }
62 | | Server({port}) => {
63 | analyzerMode: `server,
64 | reportFilename: None,
65 | openAnalyzer: false,
66 | analyzerPort: Some(port),
67 | }
68 | };
69 | };
70 | };
71 |
72 | let make = (mode: Mode.t) => mode->Mode.makeOptions->make';
73 | };
74 |
75 | [@bs.new] [@bs.module "webpack"] [@bs.scope "default"]
76 | external definePlugin: Js.Dict.t(string) => webpackPlugin = "DefinePlugin";
77 |
78 | [@bs.new] [@bs.module "webpack/lib/debug/ProfilingPlugin.js"]
79 | external makeProfilingPlugin: unit => webpackPlugin = "default";
80 |
81 | [@bs.new] [@bs.module "esbuild-loader"]
82 | external makeESBuildPlugin: Js.t('a) => webpackPlugin = "EsbuildPlugin";
83 |
84 | let getPluginWithGlobalValues =
85 | (globalEnvValuesDict: array((string, string))) => {
86 | Bundler.getGlobalEnvValuesDict(globalEnvValuesDict)->definePlugin;
87 | };
88 |
89 | module Webpack = {
90 | module Stats = {
91 | type t;
92 |
93 | type toStringOptions = {
94 | assets: bool,
95 | hash: bool,
96 | colors: bool,
97 | };
98 |
99 | [@bs.send] external hasErrors: t => bool = "hasErrors";
100 | [@bs.send] external hasWarnings: t => bool = "hasWarnings";
101 | [@bs.send] external toString': (t, toStringOptions) => string = "toString";
102 | [@bs.send] external toJson': (t, string) => Js.Json.t = "toJson";
103 |
104 | let toString = stats =>
105 | stats->toString'({assets: true, hash: true, colors: true});
106 |
107 | let toJson = stats => stats->toJson'("normal");
108 | };
109 |
110 | type compiler;
111 |
112 | [@bs.module "webpack"]
113 | external makeCompiler: Js.t({..}) => compiler = "default";
114 |
115 | [@bs.send]
116 | external run: (compiler, ('err, Js.Nullable.t(Stats.t)) => unit) => unit =
117 | "run";
118 |
119 | [@bs.send] external close: (compiler, 'closeError => unit) => unit = "close";
120 | };
121 |
122 | module WebpackDevServer = {
123 | type t;
124 |
125 | [@bs.new] [@bs.module "webpack-dev-server"]
126 | external make: (Js.t({..}), Webpack.compiler) => t = "default";
127 |
128 | [@bs.send]
129 | external startWithCallback: (t, unit => unit) => unit = "startCallback";
130 |
131 | [@bs.send] external stop: (t, unit) => Js.Promise.t(unit) = "stop";
132 | };
133 |
134 | module Mode = {
135 | type t =
136 | | Development
137 | | Production;
138 |
139 | let toString = (mode: t) =>
140 | switch (mode) {
141 | | Development => "development"
142 | | Production => "production"
143 | };
144 | };
145 |
146 | module Minimizer = {
147 | type t =
148 | | Terser
149 | | EsbuildPlugin
150 | | TerserPluginWithEsbuild;
151 | };
152 |
153 | module DevServerOptions = {
154 | module Proxy = {
155 | module DevServerTarget = {
156 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/eefa7b7fce1443e2b6ee5e34d84e142880418208/types/http-proxy/index.d.ts#L25
157 | type params = {
158 | host: option(string),
159 | socketPath: option(string),
160 | };
161 |
162 | type t =
163 | | String(string)
164 | | Params(params);
165 |
166 | [@unboxed]
167 | type unboxed =
168 | | Any('a): unboxed;
169 |
170 | let makeUnboxed = (devServerTarget: t) =>
171 | switch (devServerTarget) {
172 | | String(s) => Any(s)
173 | | Params(devServerTarget) => Any(devServerTarget)
174 | };
175 | };
176 |
177 | type devServerPathRewrite = Js.Dict.t(string);
178 |
179 | type devServerProxyTo = {
180 | target: DevServerTarget.unboxed,
181 | pathRewrite: option(devServerPathRewrite),
182 | secure: bool,
183 | changeOrigin: bool,
184 | logLevel: string,
185 | };
186 |
187 | // https://webpack.js.org/configuration/dev-server/#devserverproxy
188 | type devServerProxy = Js.Dict.t(devServerProxyTo);
189 |
190 | type target =
191 | | Host(string)
192 | | UnixSocket(string);
193 |
194 | type pathRewrite = {
195 | from: string,
196 | to_: string,
197 | };
198 |
199 | type proxyTo = {
200 | target,
201 | pathRewrite: option(pathRewrite),
202 | secure: bool,
203 | changeOrigin: bool,
204 | };
205 |
206 | type t = {
207 | from: string,
208 | to_: proxyTo,
209 | };
210 | };
211 |
212 | type listenTo =
213 | | Port(int)
214 | | UnixSocket(string);
215 |
216 | type t = {
217 | listenTo,
218 | proxy: option(array(Proxy.t)),
219 | };
220 | };
221 |
222 | let makeConfig =
223 | (
224 | ~webpackBundleAnalyzerMode: option(WebpackBundleAnalyzerPlugin.Mode.t),
225 | ~webpackDevServerOptions: option(DevServerOptions.t),
226 | ~webpackMode: Mode.t,
227 | ~webpackMinimizer: Minimizer.t,
228 | ~logger: Log.logger,
229 | ~outputDir: string,
230 | ~globalEnvValues: array((string, string)),
231 | ~renderedPages: array(RenderedPage.t),
232 | ) => {
233 | let entries =
234 | renderedPages
235 | ->Js.Array2.map(({path, entryPath, _}) =>
236 | (PagePath.toWebpackEntryName(path), entryPath)
237 | )
238 | ->Js.Dict.fromArray;
239 |
240 | let shouldMinimize = webpackMode == Production;
241 |
242 | let config = {
243 | "entry": entries,
244 |
245 | "mode": Mode.toString(webpackMode),
246 |
247 | "output": {
248 | "path": Bundler.getOutputDir(~outputDir),
249 | "publicPath": Bundler.assetPrefix,
250 | "filename": Bundler.assetsDirname ++ "/" ++ "js/[name]_[chunkhash].js",
251 | "assetModuleFilename":
252 | Bundler.assetsDirname ++ "/" ++ "[name].[hash][ext]",
253 | "hashFunction": Crypto.Hash.createMd5,
254 | "hashDigestLength": Crypto.Hash.digestLength,
255 | // Clean the output directory before emit.
256 | "clean": true,
257 | },
258 |
259 | "module": {
260 | "rules": [|
261 | {
262 | //
263 | "test": [%re {|/\.css$/|}],
264 | "use": [|MiniCssExtractPlugin.loader, "css-loader"|],
265 | },
266 | {"test": Bundler.assetRegex, "type": "asset/resource"}->Obj.magic,
267 | |],
268 | },
269 |
270 | "plugins": {
271 | let htmlWebpackPlugins =
272 | renderedPages->Js.Array2.map(({path, htmlTemplatePath, _}) => {
273 | HtmlWebpackPlugin.make({
274 | "template": htmlTemplatePath,
275 | "filename": Path.join2(PagePath.toString(path), "index.html"),
276 | "chunks": [|PagePath.toWebpackEntryName(path)|],
277 | "inject": true,
278 | "minify": {
279 | "collapseWhitespace": shouldMinimize,
280 | "keepClosingSlash": shouldMinimize,
281 | "removeComments": shouldMinimize,
282 | "removeRedundantAttributes": shouldMinimize,
283 | "removeScriptTypeAttributes": shouldMinimize,
284 | "removeStyleLinkTypeAttributes": shouldMinimize,
285 | "useShortDoctype": shouldMinimize,
286 | "minifyCSS": shouldMinimize,
287 | },
288 | })
289 | });
290 |
291 | let globalValuesPlugin = getPluginWithGlobalValues(globalEnvValues);
292 |
293 | let miniCssExtractPlugin =
294 | MiniCssExtractPlugin.make({
295 | "filename": Bundler.assetsDirname ++ "/" ++ "[name]_[chunkhash].css",
296 | });
297 |
298 | let webpackBundleAnalyzerPlugin =
299 | switch (webpackBundleAnalyzerMode) {
300 | | None => [||]
301 | | Some(mode) => [|WebpackBundleAnalyzerPlugin.make(mode)|]
302 | };
303 |
304 | Js.Array2.concat(
305 | htmlWebpackPlugins,
306 | [|miniCssExtractPlugin, globalValuesPlugin|],
307 | )
308 | ->Js.Array2.concat(webpackBundleAnalyzerPlugin);
309 | },
310 | // Explicitly disable source maps in dev mode
311 | "devtool": false,
312 | "optimization": {
313 | "runtimeChunk": {
314 | "name": "webpack-runtime",
315 | },
316 | "minimize": shouldMinimize,
317 | "minimizer": {
318 | // It's possible to use esbuild plugin as is as a minimizer.
319 | // https://github.com/privatenumber/esbuild-loader/blob/e74b94a806c906fbb8fdf877bcc4bc54df8bf213/README.md?plain=1#L183
320 | // It's also possible to use esbuild plugin together with terser plugin.
321 | // https://webpack.js.org/plugins/terser-webpack-plugin/#terseroptions
322 | // It's not clear what is better/right approach, need to investigate.
323 | switch (shouldMinimize, webpackMinimizer) {
324 | | (true, EsbuildPlugin) =>
325 | Some([|makeESBuildPlugin({"target": "es2015"})|])
326 | | (true, TerserPluginWithEsbuild) =>
327 | Some([|TerserPlugin.make({"minify": TerserPlugin.esbuildMinify})|])
328 | | (false, _)
329 | | (_, Terser) => None
330 | };
331 | },
332 | "splitChunks": {
333 | "chunks": "all",
334 | "minSize": 20000,
335 | "cacheGroups": {
336 | "framework": {
337 | "priority": 40,
338 | "name": "framework",
339 | "test": {
340 | let frameworkPackages =
341 | [|"react", "react-dom", "scheduler", "prop-types"|]
342 | ->Js.Array2.joinWith("|");
343 | let regexStr = {j|(?Js.Array2.joinWith("|");
354 | let regexStr = {j|[\\\\/]node_modules[\\\\/]($(packages))[\\\\/]|j};
355 | let regex = Js.Re.fromString(regexStr);
356 | regex;
357 | },
358 | "enforce": true,
359 | },
360 | },
361 | },
362 | },
363 | "watchOptions": {
364 | "aggregateTimeout": 1000,
365 | },
366 | "devServer": {
367 | switch (webpackDevServerOptions) {
368 | | None => None
369 | | Some({listenTo, proxy}) =>
370 | Some({
371 | // Prevent Webpack from handling SIGINT and SIGTERM signals
372 | // because we handle them in our graceful shutdown logic
373 | "setupExitSignals": false,
374 | "devMiddleware": {
375 | "stats": {
376 | switch (logger.logLevel) {
377 | | Info => "errors-warnings"
378 | | Debug => "normal"
379 | };
380 | },
381 | },
382 | "historyApiFallback": {
383 | "verbose": true,
384 | "rewrites": {
385 | // Here we add support for serving pages with dynamic path parts
386 | // We use underscore prefix in this library to mark this kind of paths: users/_id
387 | // Be build rewrite setting below like this:
388 | // from: "/^\/users\/.*/"
389 | // to: "/users/_id/index.html"
390 | let rewrites =
391 | renderedPages->Belt.Array.keepMap(page =>
392 | switch (page.path) {
393 | | Root => None
394 | | Path(segments) =>
395 | let hasDynamicPart =
396 | segments
397 | ->Js.Array2.find(segment =>
398 | segment == PagePath.dynamicSegment
399 | )
400 | ->Belt.Option.isSome;
401 |
402 | switch (hasDynamicPart) {
403 | | false => None
404 | | _true =>
405 | let pathWithAsterisks =
406 | segments
407 | ->Js.Array2.map(segment =>
408 | segment == PagePath.dynamicSegment ? ".*" : segment
409 | )
410 | ->Js.Array2.joinWith("/");
411 |
412 | let regexString = "^/" ++ pathWithAsterisks;
413 |
414 | let from = Js.Re.fromString(regexString);
415 |
416 | let to_ =
417 | Path.join3(
418 | "/",
419 | PagePath.toString(page.path),
420 | "index.html",
421 | );
422 |
423 | Some({"from": from, "to": to_});
424 | };
425 | }
426 | );
427 |
428 | logger.info(() =>
429 | Js.log2("[Webpack dev server] Path rewrites: ", rewrites)
430 | );
431 | rewrites;
432 | },
433 | },
434 | "hot": false,
435 | // static: {
436 | // directory: path.join(__dirname, "public"),
437 | // },
438 | "compress": true,
439 | "port": {
440 | switch (listenTo) {
441 | | Port(port) => Some(port)
442 | | UnixSocket(_) => None
443 | };
444 | },
445 | // TODO Should we check/remove socket file before starting or on terminating dev server?
446 | "ipc": {
447 | switch (listenTo) {
448 | | UnixSocket(path) => Some(path)
449 | | Port(_) => None
450 | };
451 | },
452 | "proxy": {
453 | switch (proxy) {
454 | | None => None
455 | | Some(proxySettings) =>
456 | let proxyDict:
457 | Js.Dict.t(DevServerOptions.Proxy.devServerProxyTo) =
458 | proxySettings
459 | ->Js.Array2.map(proxy => {
460 | let proxyTo: DevServerOptions.Proxy.devServerProxyTo = {
461 | target:
462 | switch (proxy.to_.target) {
463 | | Host(host) =>
464 | DevServerOptions.Proxy.DevServerTarget.makeUnboxed(
465 | String(host),
466 | )
467 | | UnixSocket(socketPath) =>
468 | DevServerOptions.Proxy.DevServerTarget.makeUnboxed(
469 | Params({
470 | host: None,
471 | socketPath: Some(socketPath),
472 | }),
473 | )
474 | },
475 | pathRewrite:
476 | proxy.to_.pathRewrite
477 | ->Belt.Option.map(({from, to_}) => {
478 | Js.Dict.fromList([(from, to_)])
479 | }),
480 | secure: proxy.to_.secure,
481 | changeOrigin: proxy.to_.changeOrigin,
482 | logLevel: "debug",
483 | };
484 |
485 | (proxy.from, proxyTo);
486 | })
487 | ->Js.Dict.fromArray;
488 |
489 | logger.debug(() =>
490 | Js.log2("[Webpack dev server] proxyDict: ", proxyDict)
491 | );
492 |
493 | Some(proxyDict);
494 | };
495 | },
496 | })
497 | };
498 | },
499 | };
500 |
501 | config;
502 | };
503 |
504 | let makeCompiler =
505 | (
506 | ~webpackDevServerOptions: option(DevServerOptions.t),
507 | ~logger: Log.logger,
508 | ~webpackMode: Mode.t,
509 | ~webpackMinimizer: Minimizer.t,
510 | ~globalEnvValues: array((string, string)),
511 | ~outputDir,
512 | ~webpackBundleAnalyzerMode: option(WebpackBundleAnalyzerPlugin.Mode.t),
513 | ~renderedPages: array(RenderedPage.t),
514 | ) => {
515 | let config =
516 | makeConfig(
517 | ~webpackDevServerOptions,
518 | ~webpackMode,
519 | ~logger,
520 | ~webpackMinimizer,
521 | ~outputDir,
522 | ~globalEnvValues,
523 | ~webpackBundleAnalyzerMode,
524 | ~renderedPages,
525 | );
526 | // TODO handle errors when we make compiler
527 | let compiler = Webpack.makeCompiler(config);
528 | (compiler, config);
529 | };
530 |
531 | let build =
532 | (
533 | ~webpackMode: Mode.t,
534 | ~webpackMinimizer: Minimizer.t,
535 | ~logger: Log.logger,
536 | ~outputDir,
537 | ~globalEnvValues: array((string, string)),
538 | ~webpackBundleAnalyzerMode: option(WebpackBundleAnalyzerPlugin.Mode.t),
539 | ~renderedPages: array(RenderedPage.t),
540 | )
541 | : Js.Promise.t(unit) => {
542 | let durationLabel = "[Webpack.build] duration";
543 | Js.Console.timeStart(durationLabel);
544 |
545 | logger.info(() => Js.log("[Webpack.build] Building webpack bundle..."));
546 |
547 | let (compiler, _config) =
548 | makeCompiler(
549 | ~webpackDevServerOptions=None,
550 | ~webpackMode,
551 | ~logger,
552 | ~outputDir,
553 | ~webpackMinimizer,
554 | ~globalEnvValues,
555 | ~webpackBundleAnalyzerMode: option(WebpackBundleAnalyzerPlugin.Mode.t),
556 | ~renderedPages,
557 | );
558 |
559 | Promise.make((~resolve, ~reject as _reject) => {
560 | compiler->Webpack.run((err, stats) => {
561 | switch (Js.Nullable.toOption(err)) {
562 | | Some(error) =>
563 | logger.info(() => {
564 | Js.Console.error2("[Webpack.build] Fatal error:", error);
565 | Process.exit(1);
566 | })
567 | | None =>
568 | logger.info(() => Js.log("[Webpack.build] Success!"));
569 | switch (Js.Nullable.toOption(stats)) {
570 | | None =>
571 | logger.info(() => {
572 | Js.Console.error("[Webpack.build] Error: stats object is None");
573 | Process.exit(1);
574 | })
575 | | Some(stats) =>
576 | logger.info(() => Js.log(Webpack.Stats.toString(stats)));
577 |
578 | switch (Webpack.Stats.hasErrors(stats)) {
579 | | false => ()
580 | | true =>
581 | Js.Console.error(
582 | "[Webpack.build] Error: stats object has errors",
583 | );
584 | Process.exit(1);
585 | };
586 |
587 | switch (Webpack.Stats.hasWarnings(stats)) {
588 | | false => ()
589 | | true =>
590 | logger.info(() => Js.log("[Webpack.build] Stats.hasWarnings"))
591 | };
592 |
593 | compiler->Webpack.close(closeError => {
594 | switch (Js.Nullable.toOption(closeError)) {
595 | | None => Js.Console.timeEnd(durationLabel)
596 | | Some(error) =>
597 | logger.info(() =>
598 | Js.log2("[Webpack.build] Compiler close error:", error)
599 | )
600 | };
601 |
602 | let unit = ();
603 | resolve(. unit);
604 | });
605 | };
606 | }
607 | })
608 | });
609 | };
610 |
611 | let startDevServer =
612 | (
613 | ~webpackDevServerOptions: DevServerOptions.t,
614 | ~webpackMode: Mode.t,
615 | ~webpackMinimizer: Minimizer.t,
616 | ~logger: Log.logger,
617 | ~outputDir,
618 | ~globalEnvValues: array((string, string)),
619 | ~webpackBundleAnalyzerMode: option(WebpackBundleAnalyzerPlugin.Mode.t),
620 | ~renderedPages: array(RenderedPage.t),
621 | ~onStart: unit => unit,
622 | ) => {
623 | logger.info(() => Js.log("[Webpack] Starting dev server..."));
624 | let startupDurationLabel = "[Webpack] WebpackDevServer startup duration";
625 | Js.Console.timeStart(startupDurationLabel);
626 |
627 | let (compiler, config) =
628 | makeCompiler(
629 | ~webpackDevServerOptions=Some(webpackDevServerOptions),
630 | ~webpackMode,
631 | ~logger,
632 | ~outputDir,
633 | ~webpackMinimizer,
634 | ~globalEnvValues,
635 | ~webpackBundleAnalyzerMode,
636 | ~renderedPages,
637 | );
638 |
639 | let webpackDevServerOptions = config##devServer;
640 |
641 | switch (webpackDevServerOptions) {
642 | | None =>
643 | logger.info(() =>
644 | Js.Console.error(
645 | "[Webpack] Can't start dev server, config##devServer is None",
646 | )
647 | );
648 | Process.exit(1);
649 | | Some(webpackDevServerOptions) =>
650 | let devServer = WebpackDevServer.make(webpackDevServerOptions, compiler);
651 | devServer->WebpackDevServer.startWithCallback(() => {
652 | logger.info(() => {
653 | Js.log("[Webpack] WebpackDevServer started");
654 | Js.Console.timeEnd(startupDurationLabel);
655 | onStart();
656 | })
657 | });
658 |
659 | GracefulShutdown.addTask(() => {
660 | Js.log("[Webpack] Stopping dev server...");
661 |
662 | Js.Global.setTimeout(
663 | () => {
664 | Js.log("[Webpack] Failed to gracefully shutdown.");
665 | Process.exit(1);
666 | },
667 | GracefulShutdown.gracefulShutdownTimeout,
668 | )
669 | ->ignore;
670 |
671 | devServer
672 | ->WebpackDevServer.stop()
673 | ->Promise.map(() =>
674 | Js.log("[Webpack] Dev server stopped successfully")
675 | );
676 | });
677 | };
678 | };
679 |
--------------------------------------------------------------------------------
/src/PageBuilder.re:
--------------------------------------------------------------------------------
1 | type pageAppArtifactsType =
2 | | Reason
3 | | Js;
4 |
5 | type componentWithData('a) = {
6 | component: 'a => React.element,
7 | data: 'a,
8 | };
9 |
10 | type component =
11 | | ComponentWithoutData(React.element)
12 | | ComponentWithData(componentWithData('a)): component;
13 |
14 | type wrapperComponentWithData('data) = {
15 | component: ('data, React.element) => React.element,
16 | data: 'data,
17 | };
18 |
19 | type wrapperComponent =
20 | | WrapperWithChildren(React.element => React.element)
21 | | WrapperWithDataAndChildren(wrapperComponentWithData('a))
22 | : wrapperComponent;
23 |
24 | type hydrationMode =
25 | | FullHydration
26 | | PartialHydration;
27 |
28 | type pageWrapper = {
29 | component: wrapperComponent,
30 | modulePath: string,
31 | };
32 |
33 | type page = {
34 | hydrationMode,
35 | pageWrapper: option(pageWrapper),
36 | component,
37 | modulePath: string,
38 | path: PagePath.t,
39 | headCssFilepaths: array(string),
40 | globalValues: option(array((string, Js.Json.t))),
41 | headScripts: array(string),
42 | bodyScripts: array(string),
43 | };
44 |
45 | module PageData = {
46 | type t =
47 | | PageWrapperData
48 | | PageData;
49 |
50 | let toValueName = (t: t) =>
51 | switch (t) {
52 | | PageWrapperData => "pageWrapperData"
53 | | PageData => "pageData"
54 | };
55 | };
56 |
57 | let unsafeStringifyPropValue = data => {
58 | // We need a way to take a prop value of any type and inject it to generated React app template.
59 | // This is unsafe. Prop value should contain only values that is possible to JSON.stringify<->JSON.parse.
60 | // So it should be composed only of simple values. Types like functions, dates, promises etc can't be stringified.
61 | Jsesc.jsesc(
62 | data,
63 | );
64 | };
65 |
66 | let wrapJsTextWithScriptTag = (jsText: string) => {j||j};
67 |
68 | let globalValuesToScriptTag =
69 | (globalValues: array((string, Js.Json.t))): string => {
70 | globalValues
71 | ->Js.Array2.map(((key, value)) => {
72 | let keyS: string =
73 | switch (Js.Json.stringifyAny(key)) {
74 | | Some(key) => key
75 | | None =>
76 | Js.Console.error2(
77 | "[globalValuesToScriptTag] Failed to stringify JSON (globalValues key). key:",
78 | key,
79 | );
80 | Process.exit(1);
81 | };
82 | let valueS: string =
83 | switch (Js.Json.stringifyAny(value)) {
84 | | Some(value) => value
85 | | None =>
86 | Js.Console.error4(
87 | "[globalValuesToScriptTag] Failed to stringify JSON (globalValues value). key:",
88 | key,
89 | "value:",
90 | value,
91 | );
92 | Process.exit(1);
93 | };
94 | {j|globalThis[$(keyS)] = $(valueS)|j};
95 | })
96 | ->Js.Array2.joinWith("\n")
97 | ->wrapJsTextWithScriptTag;
98 | };
99 |
100 | let getArtifactsOutputDir = (~outputDir) =>
101 | Path.join2(outputDir, "artifacts");
102 |
103 | let pagePathToPageAppModuleName =
104 | (~pageAppArtifactsSuffix, ~pagePath, ~moduleName) => {
105 | let modulePrefix =
106 | pagePath
107 | ->Js.String2.replaceByRe([%re {|/\//g|}], "")
108 | ->Js.String2.replaceByRe([%re {|/-/g|}], "")
109 | ->Js.String2.replaceByRe([%re {|/\./g|}], "");
110 |
111 | modulePrefix ++ moduleName ++ "__PageApp" ++ pageAppArtifactsSuffix;
112 | };
113 |
114 | let groupScripts = scripts =>
115 | switch (scripts) {
116 | | [||] => ""
117 | | scripts => ""
118 | };
119 |
120 | let renderHtmlTemplate =
121 | (
122 | ~hydrationMode: hydrationMode,
123 | ~modulesWithHydration__Mutable: array(string),
124 | ~pageElement: React.element,
125 | ~headCssFilepaths: array(string),
126 | ~globalValues: array((string, Js.Json.t)),
127 | ~headScripts: array(string),
128 | ~bodyScripts: array(string),
129 | )
130 | : string => {
131 | let pageElement =
132 | switch (hydrationMode) {
133 | | FullHydration => pageElement
134 | | PartialHydration =>
135 |
137 | pageElement
138 |
139 | };
140 |
141 | let html = ReactDOMServer.renderToString(pageElement);
142 |
143 | let Emotion.Server.{html: renderedHtml, css, ids} =
144 | Emotion.Server.extractCritical(html);
145 |
146 | let emotionIds = ids->Js.Array2.joinWith(" ");
147 |
148 | // https://github.com/emotion-js/emotion/blob/92be52d894c7d81d013285e9dfe90820e6b178f8/packages/css/src/index.js#L15
149 | let emotionCacheKey = "css";
150 |
151 | let emotionStyleTag = {j||j};
152 |
153 | let headCss =
154 | switch (headCssFilepaths) {
155 | | [||] => None
156 | | cssFiles =>
157 | Some(
158 | cssFiles
159 | ->Js.Array2.map(filepath => Fs.readFileSyncAsUtf8(filepath))
160 | ->Js.Array2.joinWith("\n"),
161 | )
162 | };
163 |
164 | let headCssStyleTag =
165 | switch (headCss) {
166 | | None => ""
167 | | Some(css) => ""
168 | };
169 |
170 | let helmet = ReactHelmet.renderStatic();
171 |
172 | let htmlAttributes = helmet.htmlAttributes.toString();
173 | let title = helmet.title.toString();
174 | let meta = helmet.meta.toString();
175 | let link = helmet.link.toString();
176 | let script = helmet.script.toString();
177 | let noscript = helmet.noscript.toString();
178 | let style = helmet.style.toString();
179 | let bodyAttributes = helmet.bodyAttributes.toString();
180 | let scriptTagWithGlobalValues: string =
181 | globalValuesToScriptTag(globalValues);
182 | let headScript: string = headScripts->groupScripts;
183 | let bodyScript: string = bodyScripts->groupScripts;
184 |
185 | {j|
186 |
187 |
188 |
189 | $(title)
190 | $(meta)
191 | $(link)
192 | $(script)
193 | $(noscript)
194 | $(style)
195 | $(headCssStyleTag)
196 | $(emotionStyleTag)
197 | $(scriptTagWithGlobalValues)
198 | $(headScript)
199 |
200 |
201 | $(bodyScript)
202 | $(renderedHtml)
203 |
204 |
205 | |j};
206 | };
207 |
208 | type processedDataProp = {
209 | jsDataFileContent: string,
210 | jsDataFilepath: string,
211 | };
212 |
213 | let pageWrappersDataDirname = "__pageWrappersData";
214 |
215 | module ReasonArtifact = {
216 | let renderReactAppTemplate =
217 | (
218 | ~importPageWrapperDataString="",
219 | ~importPageDataString="",
220 | elementString: string,
221 | ) => {
222 | {j|
223 | $(importPageWrapperDataString)
224 | $(importPageDataString)
225 |
226 | switch (ReactDOM.querySelector("#root")) {
227 | | Some(root) => ReactDOM.hydrate($(elementString), root)
228 | | None => ()
229 | };
230 | |j};
231 | };
232 |
233 | type processedDataPropReason = {
234 | processedDataProp,
235 | importString: string,
236 | };
237 |
238 | type processedPage = {
239 | element: React.element,
240 | elementString: string,
241 | pageDataProp: option(processedDataPropReason),
242 | pageWrapperDataProp: option(processedDataPropReason),
243 | };
244 |
245 | let makeStringToImportJsFileFromReason =
246 | (
247 | ~pageDataType: PageData.t,
248 | ~jsDataFilename: string,
249 | ~relativePathToDataDir: string,
250 | ) => {
251 | let valueName = PageData.toValueName(pageDataType);
252 | {j|
253 | type $(valueName);
254 | [@bs.module "$(relativePathToDataDir)/$(jsDataFilename)"] external $(valueName): $(valueName) = "data";|j};
255 | };
256 |
257 | let makeDataPropString = (pageDataType: PageData.t) => {
258 | let dataValueName = PageData.toValueName(pageDataType);
259 | {j|{$(dataValueName)->Obj.magic}|j};
260 | };
261 |
262 | let makeProcessedDataProp =
263 | (
264 | ~data: 'a,
265 | ~pageDataType: PageData.t,
266 | ~moduleName: string,
267 | ~pageOutputDir: string,
268 | ~melangePageOutputDir: option(string),
269 | ~pageWrappersDataDir,
270 | )
271 | : processedDataPropReason => {
272 | let relativePathToDataDir =
273 | switch (pageDataType) {
274 | | PageData =>
275 | switch (melangePageOutputDir) {
276 | | None => "."
277 | | Some(melangePageOutputDir) =>
278 | let relativePath =
279 | Path.relative(~from=melangePageOutputDir, ~to_=pageOutputDir);
280 | relativePath;
281 | }
282 |
283 | | PageWrapperData =>
284 | let from =
285 | switch (melangePageOutputDir) {
286 | | None => pageOutputDir
287 | | Some(melangePageOutputDir) => melangePageOutputDir
288 | };
289 | let relativePath = Path.relative(~from, ~to_=pageWrappersDataDir);
290 | if (relativePath->Js.String2.startsWith(pageWrappersDataDirname)) {
291 | "./" ++ relativePath;
292 | } else {
293 | relativePath;
294 | };
295 | };
296 |
297 | let stringifiedData = unsafeStringifyPropValue(data);
298 |
299 | let propDataHash = Crypto.Hash.stringToHash(stringifiedData);
300 |
301 | let jsDataFilename = moduleName ++ "_Data_" ++ propDataHash ++ ".mjs";
302 |
303 | let importString =
304 | makeStringToImportJsFileFromReason(
305 | ~pageDataType,
306 | ~relativePathToDataDir,
307 | ~jsDataFilename,
308 | );
309 |
310 | let jsDataFileContent = {j|export const data = $(stringifiedData)|j};
311 |
312 | let jsDataFilepath =
313 | switch (pageDataType) {
314 | | PageData => Path.join2(pageOutputDir, jsDataFilename)
315 | | PageWrapperData => Path.join2(pageWrappersDataDir, jsDataFilename)
316 | };
317 |
318 | {
319 | processedDataProp: {
320 | jsDataFileContent,
321 | jsDataFilepath,
322 | },
323 | importString,
324 | };
325 | };
326 |
327 | let dataPropName = "data";
328 |
329 | let processPageComponentWithWrapper =
330 | (
331 | ~pageComponent: component,
332 | ~pageWrapper: option(pageWrapper),
333 | ~pageModuleName: string,
334 | ~pageOutputDir: string,
335 | ~melangePageOutputDir: option(string),
336 | ~pageWrappersDataDir: string,
337 | )
338 | : processedPage => {
339 | let {element, elementString, pageDataProp, _} =
340 | switch (pageComponent) {
341 | | ComponentWithoutData(element) => {
342 | element,
343 | elementString: "<" ++ pageModuleName ++ " />",
344 | pageDataProp: None,
345 | pageWrapperDataProp: None,
346 | }
347 | | ComponentWithData({component, data}) =>
348 | let pageDataType = PageData.PageData;
349 | let dataPropString = makeDataPropString(pageDataType);
350 | let elementString =
351 | "<"
352 | ++ pageModuleName
353 | ++ " "
354 | ++ dataPropName
355 | ++ "="
356 | ++ dataPropString
357 | ++ " />";
358 |
359 | let element = component(data);
360 |
361 | let pageDataProp =
362 | makeProcessedDataProp(
363 | ~pageDataType,
364 | ~data,
365 | ~moduleName=pageModuleName,
366 | ~pageOutputDir,
367 | ~melangePageOutputDir,
368 | ~pageWrappersDataDir,
369 | );
370 |
371 | {
372 | element,
373 | elementString,
374 | pageDataProp: Some(pageDataProp),
375 | pageWrapperDataProp: None,
376 | };
377 | };
378 |
379 | switch (pageWrapper) {
380 | | None => {
381 | element,
382 | elementString,
383 | pageDataProp,
384 | pageWrapperDataProp: None,
385 | }
386 | | Some({component, modulePath}) =>
387 | let wrapperModuleName = Utils.getModuleNameFromModulePath(modulePath);
388 | switch (component) {
389 | | WrapperWithChildren(f) =>
390 | let wrapperOpenTag = "<" ++ wrapperModuleName ++ ">";
391 | let wrapperCloseTag = "" ++ wrapperModuleName ++ ">";
392 | let wrappedElementString =
393 | wrapperOpenTag ++ elementString ++ wrapperCloseTag;
394 |
395 | let wrappedElement = f(element);
396 |
397 | {
398 | element: wrappedElement,
399 | elementString: wrappedElementString,
400 | pageDataProp,
401 | pageWrapperDataProp: None,
402 | };
403 | | WrapperWithDataAndChildren({component, data}) =>
404 | let pageDataType = PageData.PageWrapperData;
405 | let dataPropString = makeDataPropString(pageDataType);
406 | let wrapperOpenTag =
407 | "<"
408 | ++ wrapperModuleName
409 | ++ " "
410 | ++ dataPropName
411 | ++ "="
412 | ++ dataPropString
413 | ++ " >";
414 | let wrapperCloseTag = "" ++ wrapperModuleName ++ ">";
415 | let wrappedElementString =
416 | wrapperOpenTag ++ elementString ++ wrapperCloseTag;
417 |
418 | let wrappedElement = component(data, element);
419 |
420 | let pageWrapperDataProp =
421 | makeProcessedDataProp(
422 | ~pageDataType,
423 | ~data,
424 | ~moduleName=wrapperModuleName,
425 | ~pageOutputDir,
426 | ~melangePageOutputDir,
427 | ~pageWrappersDataDir,
428 | );
429 |
430 | {
431 | element: wrappedElement,
432 | elementString: wrappedElementString,
433 | pageDataProp,
434 | pageWrapperDataProp: Some(pageWrapperDataProp),
435 | };
436 | };
437 | };
438 | };
439 | };
440 |
441 | module JsArtifact = {
442 | let renderElementTemplate =
443 | (
444 | ~componentName,
445 | ~dataProp: option(string),
446 | ~childrenProp: option(string),
447 | ) => {
448 | let dataPropString: string =
449 | switch (dataProp) {
450 | | None => "undefined"
451 | | Some(dataProp) => dataProp
452 | };
453 | let childrenPropString: string =
454 | switch (childrenProp) {
455 | | None => "undefined"
456 | | Some(childrenProp) => childrenProp
457 | };
458 | {j|
459 | React.createElement($(componentName).make, {
460 | data: $(dataPropString),
461 | children: $(childrenPropString),
462 | })
463 | |j};
464 | };
465 |
466 | let renderReactAppTemplate =
467 | (
468 | ~pageArtifactPath: string,
469 | ~pageWrapperArtifactPath: option(string),
470 | ~pageDataPath: option(string),
471 | ~pageWrapperDataPath: option(string),
472 | ) => {
473 | let (pageDataImport, pageDataProp) =
474 | switch (pageDataPath) {
475 | | None => ("", None)
476 | | Some(pageDataPath) =>
477 | let dataPropImport = {j|import {data as pageData} from "$(pageDataPath)";|j};
478 | let dataValueName = "pageData";
479 | (dataPropImport, Some(dataValueName));
480 | };
481 |
482 | let pageElement =
483 | renderElementTemplate(
484 | ~componentName="Page",
485 | ~dataProp=pageDataProp,
486 | ~childrenProp=None,
487 | );
488 |
489 | let (pageWrapperImport, pageWrapperDataImport, elementString) =
490 | switch (pageWrapperArtifactPath) {
491 | | None => ("", "", pageElement)
492 | | Some(pageWrapperArtifactPath) =>
493 | let pageWrapperImport = {j|import * as PageWrapper from "$(pageWrapperArtifactPath)";|j};
494 | switch (pageWrapperDataPath) {
495 | | None =>
496 | let pageWrapperElement =
497 | renderElementTemplate(
498 | ~componentName="PageWrapper",
499 | ~dataProp=None,
500 | ~childrenProp=Some(pageElement),
501 | );
502 | (pageWrapperImport, "", pageWrapperElement);
503 | | Some(pageWrapperDataPath) =>
504 | let dataPropImport = {j|import {data as pageWrapperData} from "$(pageWrapperDataPath)";|j};
505 | let dataValueName = "pageWrapperData";
506 | let pageWrapperElement =
507 | renderElementTemplate(
508 | ~componentName="PageWrapper",
509 | ~dataProp=Some(dataValueName),
510 | ~childrenProp=Some(pageElement),
511 | );
512 | (pageWrapperImport, dataPropImport, pageWrapperElement);
513 | };
514 | };
515 |
516 | {j|
517 | import * as React from "react";
518 | import * as ReactDom from "react-dom";
519 | import * as Page from "$(pageArtifactPath)";
520 | $(pageDataImport)
521 | $(pageWrapperImport)
522 | $(pageWrapperDataImport)
523 |
524 | const root = document.querySelector("#root");
525 |
526 | if (root !== null) {
527 | ReactDom.hydrate($(elementString), root);
528 | }
529 | |j};
530 | };
531 |
532 | type processedPage = {
533 | element: React.element,
534 | pageDataProp: option(processedDataProp),
535 | pageWrapperDataProp: option(processedDataProp),
536 | };
537 |
538 | let makeProcessedDataProp =
539 | (
540 | ~data: 'a,
541 | ~pageDataType: PageData.t,
542 | ~moduleName: string,
543 | ~pageOutputDir: string,
544 | ~pageWrappersDataDir,
545 | )
546 | : processedDataProp => {
547 | let stringifiedData = unsafeStringifyPropValue(data);
548 |
549 | let propDataHash = Crypto.Hash.stringToHash(stringifiedData);
550 |
551 | let jsDataFilename = moduleName ++ "_Data_" ++ propDataHash ++ ".mjs";
552 |
553 | let jsDataFileContent = {j|export const data = $(stringifiedData)|j};
554 |
555 | let jsDataFilepath =
556 | switch (pageDataType) {
557 | | PageData => Path.join2(pageOutputDir, jsDataFilename)
558 | | PageWrapperData => Path.join2(pageWrappersDataDir, jsDataFilename)
559 | };
560 |
561 | {jsDataFileContent, jsDataFilepath};
562 | };
563 |
564 | let processPageComponentWithWrapperJs =
565 | (
566 | ~pageComponent: component,
567 | ~pageWrapper: option(pageWrapper),
568 | ~pageModuleName: string,
569 | ~pageOutputDir: string,
570 | ~pageWrappersDataDir: string,
571 | )
572 | : processedPage => {
573 | let {element, pageDataProp, _} =
574 | switch (pageComponent) {
575 | | ComponentWithoutData(element) => {
576 | element,
577 | pageDataProp: None,
578 | pageWrapperDataProp: None,
579 | }
580 | | ComponentWithData({component, data}) =>
581 | let pageDataType = PageData.PageData;
582 | let element = component(data);
583 | let pageDataProp =
584 | makeProcessedDataProp(
585 | ~pageDataType,
586 | ~data,
587 | ~moduleName=pageModuleName,
588 | ~pageOutputDir,
589 | ~pageWrappersDataDir,
590 | );
591 | {
592 | element,
593 | pageDataProp: Some(pageDataProp),
594 | pageWrapperDataProp: None,
595 | };
596 | };
597 |
598 | switch (pageWrapper) {
599 | | None => {element, pageDataProp, pageWrapperDataProp: None}
600 | | Some({component, modulePath}) =>
601 | let wrapperModuleName = Utils.getModuleNameFromModulePath(modulePath);
602 | switch (component) {
603 | | WrapperWithChildren(f) =>
604 | let wrappedElement = f(element);
605 | {element: wrappedElement, pageDataProp, pageWrapperDataProp: None};
606 | | WrapperWithDataAndChildren({component, data}) =>
607 | let pageDataType = PageData.PageWrapperData;
608 | let wrappedElement = component(data, element);
609 | let pageWrapperDataProp =
610 | makeProcessedDataProp(
611 | ~pageDataType,
612 | ~data,
613 | ~moduleName=wrapperModuleName,
614 | ~pageOutputDir,
615 | ~pageWrappersDataDir,
616 | );
617 | {
618 | element: wrappedElement,
619 | pageDataProp,
620 | pageWrapperDataProp: Some(pageWrapperDataProp),
621 | };
622 | };
623 | };
624 | };
625 | };
626 |
627 | let buildPageHtmlAndReactApp =
628 | (
629 | ~pageAppArtifactsType: pageAppArtifactsType,
630 | ~outputDir: string,
631 | ~melangeOutputDir: option(string),
632 | ~logger: Log.logger,
633 | ~pageAppArtifactsSuffix: string,
634 | page: page,
635 | ) => {
636 | let artifactsOutputDir = getArtifactsOutputDir(~outputDir);
637 |
638 | let moduleName: string = Utils.getModuleNameFromModulePath(page.modulePath);
639 |
640 | let pagePath: string = page.path->PagePath.toString;
641 |
642 | let pageOutputDir = Path.join2(artifactsOutputDir, pagePath);
643 |
644 | // Melange emits compiled JS files to a separate dir (not next to Reason files).
645 | // We need to handle it to build correct relative paths to entry files and to prop data files.
646 | let melangePageOutputDir =
647 | switch (melangeOutputDir) {
648 | | None => None
649 | | Some(melangeOutputDir) =>
650 | let melangeArtifactsOutputDir =
651 | getArtifactsOutputDir(~outputDir=melangeOutputDir);
652 | Some(Path.join2(melangeArtifactsOutputDir, pagePath));
653 | };
654 |
655 | let pageWrappersDataDir =
656 | Path.join2(artifactsOutputDir, pageWrappersDataDirname);
657 |
658 | logger.debug(() =>
659 | Js.log(
660 | {j|[PageBuilder.buildPageHtmlAndReactApp] Building page module: $(moduleName), page path: $(pagePath)|j},
661 | )
662 | );
663 |
664 | logger.debug(() =>
665 | Js.log2(
666 | "[PageBuilder.buildPageHtmlAndReactApp] Output dir for page: ",
667 | pageOutputDir,
668 | )
669 | );
670 |
671 | let modulesWithHydration__Mutable = [||];
672 |
673 | let (resultHtml, resultReactApp, pageDataProp, pageWrapperDataProp) =
674 | switch (pageAppArtifactsType) {
675 | | Reason =>
676 | let {
677 | ReasonArtifact.element,
678 | elementString,
679 | pageDataProp,
680 | pageWrapperDataProp,
681 | } =
682 | ReasonArtifact.processPageComponentWithWrapper(
683 | ~pageComponent=page.component,
684 | ~pageWrapper=page.pageWrapper,
685 | ~pageModuleName=moduleName,
686 | ~pageOutputDir,
687 | ~melangePageOutputDir,
688 | ~pageWrappersDataDir,
689 | );
690 | let resultHtml: string =
691 | renderHtmlTemplate(
692 | ~hydrationMode=page.hydrationMode,
693 | ~modulesWithHydration__Mutable,
694 | ~pageElement=element,
695 | ~headCssFilepaths=page.headCssFilepaths,
696 | ~globalValues=Belt.Option.getWithDefault(page.globalValues, [||]),
697 | ~headScripts=page.headScripts,
698 | ~bodyScripts=page.bodyScripts,
699 | );
700 | let resultReactApp =
701 | switch (page.hydrationMode) {
702 | | FullHydration =>
703 | ReasonArtifact.renderReactAppTemplate(
704 | ~importPageWrapperDataString=?
705 | Belt.Option.map(pageWrapperDataProp, v => v.importString),
706 | ~importPageDataString=?
707 | Belt.Option.map(pageDataProp, v => v.importString),
708 | elementString,
709 | )
710 | | PartialHydration =>
711 | PartialHydration.renderReactAppTemplate(
712 | ~modulesWithHydration__Mutable,
713 | )
714 | };
715 | (
716 | resultHtml,
717 | resultReactApp,
718 | pageDataProp->Belt.Option.map(v => v.processedDataProp),
719 | pageWrapperDataProp->Belt.Option.map(v => v.processedDataProp),
720 | );
721 | | Js =>
722 | let {JsArtifact.element, pageDataProp, pageWrapperDataProp} =
723 | JsArtifact.processPageComponentWithWrapperJs(
724 | ~pageComponent=page.component,
725 | ~pageWrapper=page.pageWrapper,
726 | ~pageModuleName=moduleName,
727 | ~pageOutputDir,
728 | ~pageWrappersDataDir,
729 | );
730 | let resultHtml: string =
731 | renderHtmlTemplate(
732 | ~hydrationMode=page.hydrationMode,
733 | ~modulesWithHydration__Mutable,
734 | ~pageElement=element,
735 | ~headCssFilepaths=page.headCssFilepaths,
736 | ~globalValues=Belt.Option.getWithDefault(page.globalValues, [||]),
737 | ~headScripts=page.headScripts,
738 | ~bodyScripts=page.bodyScripts,
739 | );
740 | let resultReactApp =
741 | JsArtifact.renderReactAppTemplate(
742 | ~pageArtifactPath=page.modulePath,
743 | ~pageWrapperArtifactPath={
744 | switch (page.pageWrapper) {
745 | | None => None
746 | | Some(wrapper) => Some(wrapper.modulePath)
747 | };
748 | },
749 | ~pageDataPath={
750 | switch (pageDataProp) {
751 | | None => None
752 | | Some({jsDataFilepath, _}) => Some(jsDataFilepath)
753 | };
754 | },
755 | ~pageWrapperDataPath={
756 | switch (pageWrapperDataProp) {
757 | | None => None
758 | | Some({jsDataFilepath, _}) => Some(jsDataFilepath)
759 | };
760 | },
761 | );
762 | (resultHtml, resultReactApp, pageDataProp, pageWrapperDataProp);
763 | };
764 |
765 | let pageAppModuleName =
766 | pagePathToPageAppModuleName(
767 | ~pageAppArtifactsSuffix,
768 | ~pagePath,
769 | ~moduleName,
770 | );
771 |
772 | let resultHtmlPath = Path.join2(pageOutputDir, "index.html");
773 |
774 | let mkDirPromises =
775 | [|
776 | Fs.Promises.mkDir(pageOutputDir, {recursive: true})
777 | ->Promise.Result.catch(
778 | ~context=
779 | "[PageBuilder.buildPageHtmlAndReactApp] [Fs.Promises.mkDir(pageOutputDir)]",
780 | ),
781 | Fs.Promises.mkDir(pageWrappersDataDir, {recursive: true})
782 | ->Promise.Result.catch(
783 | ~context=
784 | "[PageBuilder.buildPageHtmlAndReactApp] [Fs.Promises.mkDir(pageWrappersDataDir)]",
785 | ),
786 | |]
787 | ->Promise.all
788 | ->Promise.Result.all;
789 |
790 | let writeFilePromises =
791 | mkDirPromises->Promise.Result.flatMap(_createdDirs => {
792 | let pageAppModuleExtension =
793 | switch (pageAppArtifactsType) {
794 | | Reason => ".re"
795 | | Js => ".mjs"
796 | };
797 |
798 | let reactAppFilename = pageAppModuleName ++ pageAppModuleExtension;
799 |
800 | let resultHtmlFilePromise =
801 | Fs.Promises.writeFile(~path=resultHtmlPath, ~data=resultHtml)
802 | ->Promise.Result.catch(
803 | ~context=
804 | "[PageBuilder.buildPageHtmlAndReactApp] [resultHtmlFilePromise]",
805 | );
806 |
807 | let resultReactAppFilePromise =
808 | Fs.Promises.writeFile(
809 | ~path=Path.join2(pageOutputDir, reactAppFilename),
810 | ~data=resultReactApp,
811 | )
812 | ->Promise.Result.catch(
813 | ~context=
814 | "[PageBuilder.buildPageHtmlAndReactApp] [resultReactAppFilePromise]",
815 | );
816 |
817 | let jsFilesPromises =
818 | [|pageWrapperDataProp, pageDataProp|]
819 | ->Js.Array2.map(data =>
820 | switch (data) {
821 | | None => Promise.resolve(Belt.Result.Ok())
822 | | Some({jsDataFileContent, jsDataFilepath, _}) =>
823 | Fs.Promises.writeFile(
824 | ~path=jsDataFilepath,
825 | ~data=jsDataFileContent,
826 | )
827 | ->Promise.Result.catch(
828 | ~context=
829 | "[PageBuilder.buildPageHtmlAndReactApp] [jsFilesPromises]",
830 | )
831 | }
832 | );
833 |
834 | let promises =
835 | Js.Array2.concat(
836 | [|resultHtmlFilePromise, resultReactAppFilePromise|],
837 | jsFilesPromises,
838 | );
839 |
840 | promises->Promise.all->Promise.Result.all;
841 | });
842 |
843 | writeFilePromises->Promise.Result.map(_createdFiles => {
844 | let compiledReactAppFilename =
845 | switch (pageAppArtifactsType) {
846 | | Reason => ".bs.js"
847 | | Js => ".mjs"
848 | };
849 |
850 | let compiledReactAppFilename =
851 | pageAppModuleName ++ compiledReactAppFilename;
852 |
853 | let renderedPage: RenderedPage.t = {
854 | path: page.path,
855 | entryPath: {
856 | switch (pageAppArtifactsType) {
857 | | Js => Path.join2(pageOutputDir, compiledReactAppFilename)
858 | | Reason =>
859 | Path.join2(
860 | melangePageOutputDir->Belt.Option.getWithDefault(pageOutputDir),
861 | compiledReactAppFilename,
862 | )
863 | };
864 | },
865 | outputDir: pageOutputDir,
866 | htmlTemplatePath: resultHtmlPath,
867 | };
868 |
869 | logger.debug(() =>
870 | Js.log2(
871 | "[PageBuilder.buildPageHtmlAndReactApp] Build finished: ",
872 | moduleName,
873 | )
874 | );
875 |
876 | renderedPage;
877 | });
878 | };
879 |
--------------------------------------------------------------------------------