diff --git a/.ocamlformat b/.ocamlformat
index 49a0a48..1dfdff7 100644
--- a/.ocamlformat
+++ b/.ocamlformat
@@ -1,4 +1,4 @@
profile = default
let-binding-spacing=double-semicolon
break-infix-before-func=true
-version = 0.26.1
+version = 0.26.2
diff --git a/README.md b/README.md
index 2d6b476..94e2b40 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,16 @@ be coming soon! In the mean time, please check in at
[The Caravan Discord](https://discord.gg/fNvVdsUWHE) for
help getting started working on `create-melange-app`.
+
Run the CLI locally
+
+```bash
+npm install
+opam install . --dps-only
+dune build
+
+node ./build/src/cli.mjs
+```
+
diff --git a/create_melange_app.opam b/create_melange_app.opam
index 94e0d6d..b422393 100644
--- a/create_melange_app.opam
+++ b/create_melange_app.opam
@@ -17,6 +17,7 @@ depends: [
"reason-react" {>= "0.15.0"}
"reason-react-ppx"
"ppx_deriving"
+ "ocamlformat" {= "0.26.2" & with-dev-setup}
"odoc" {with-doc}
]
build: [
diff --git a/dune-project b/dune-project
index 9c8301f..c2fcf7d 100644
--- a/dune-project
+++ b/dune-project
@@ -36,6 +36,10 @@
(reason-react
(>= 0.15.0))
reason-react-ppx
- ppx_deriving)
+ ppx_deriving
+ (ocamlformat
+ (and
+ (= 0.26.2)
+ :with-dev-setup)))
(tags
(cli, react, reasonml, ocaml, melange)))
diff --git a/src/core/app_module.ml b/src/core/app_module.ml
index 5936ed0..2f1266f 100644
--- a/src/core/app_module.ml
+++ b/src/core/app_module.ml
@@ -8,8 +8,7 @@ let template (configuration : Configuration.t) =
in
let name =
match (configuration.syntax_preference, configuration.is_react_app) with
- | `ReasonML, true
- | `ReasonML, false -> "App.re.tmpl"
+ | `ReasonML, true | `ReasonML, false -> "App.re.tmpl"
| `OCaml, true -> "App.mlx.tmpl"
| `OCaml, false -> "App.ml.tmpl"
in
diff --git a/src/core/backend_files.ml b/src/core/backend_files.ml
new file mode 100644
index 0000000..2efe53b
--- /dev/null
+++ b/src/core/backend_files.ml
@@ -0,0 +1,84 @@
+open Bindings
+
+type backend_options = {
+ project_directory : string;
+ backend_framework : Configuration.backend_framework option;
+ project_name : string;
+}
+
+module Copy :
+ Process.S with type input = backend_options and type output = unit = struct
+ type input = backend_options
+ type output = unit
+
+ let name = "copy backend files"
+
+ let base_path =
+ Node.Path.join
+ [|
+ Nodejs.Util.__dirname [%mel.raw "import.meta.url"];
+ "..";
+ "templates";
+ "extensions";
+ "backend";
+ "dream";
+ |]
+ ;;
+
+ let error_message =
+ {|
+ Failed to copy backend files to project directory
+
+ The scaffolding process failed while copying backend files. Please try
+ running `create-melange-app` again and choose to `Clear` the project
+ directory created by this run.
+
+ If the problem persists, please open an issue at
+ github.com/dmmulroy/create-melange-app/issues, and or join our discord for
+ help at https://discord.gg/fNvVdsUWHE.
+ |}
+ ;;
+
+ let exec (input : input) =
+ match input.backend_framework with
+ | Some `Dream ->
+ let open Promise_result.Syntax.Let in
+ let backend_dest =
+ Node.Path.join [| input.project_directory; "backend" |]
+ in
+ let bin_dest = Node.Path.join [| backend_dest; "bin" |] in
+ let+ _ =
+ Fs_extra.ensureDir backend_dest |> Promise_result.of_js_promise
+ in
+ let+ _ = Fs_extra.ensureDir bin_dest |> Promise_result.of_js_promise in
+
+ let+ _ =
+ Fs_extra.copy
+ (Node.Path.join [| base_path; "bin"; "main.ml.tmpl" |])
+ (Node.Path.join [| bin_dest; "main.ml.tmpl" |])
+ |> Promise_result.of_js_promise
+ in
+ let+ _ =
+ Fs_extra.copy
+ (Node.Path.join [| base_path; "bin"; "dune.tmpl" |])
+ (Node.Path.join [| bin_dest; "dune.tmpl" |])
+ |> Promise_result.of_js_promise
+ in
+ let+ _ =
+ Fs_extra.copy
+ (Node.Path.join [| base_path; "dune.tmpl" |])
+ (Node.Path.join [| backend_dest; "dune.tmpl" |])
+ |> Promise_result.of_js_promise
+ in
+ let+ _ =
+ Fs_extra.copy
+ (Node.Path.join [| base_path; "README.md.tmpl" |])
+ (Node.Path.join [| backend_dest; "README.md.tmpl" |])
+ |> Promise_result.of_js_promise
+ in
+
+ Promise_result.resolve_ok ()
+ |> Promise_result.log_and_map_error (Fun.const error_message)
+ | None -> Promise_result.resolve_ok ()
+ ;;
+end
diff --git a/src/core/configuration.ml b/src/core/configuration.ml
index ebd834e..9493bda 100644
--- a/src/core/configuration.ml
+++ b/src/core/configuration.ml
@@ -22,11 +22,47 @@ let syntax_preference_of_string str =
| _ -> failwith "Invalid syntax preference"
;;
+type project_type = [ `Frontend | `Fullstack ]
+
+let project_type_to_string = function
+ | `Frontend -> "Frontend"
+ | `Fullstack -> "Fullstack"
+;;
+
+let project_type_of_string str =
+ str |> String.lowercase_ascii
+ |> function
+ | "frontend" -> `Frontend
+ | "fullstack" -> `Fullstack
+ | _ -> failwith "Invalid project type"
+;;
+
+type backend_framework = [ `Dream ]
+
+let backend_framework_to_string = function `Dream -> "Dream"
+
+let backend_framework_of_string str =
+ str |> String.lowercase_ascii
+ |> function "dream" -> `Dream | _ -> failwith "Invalid backend framework"
+;;
+
+type api_features = [ `REST ]
+
+let api_features_to_string = function `REST -> "REST"
+
+let api_features_of_string str =
+ str |> String.lowercase_ascii
+ |> function "rest" -> `REST | _ -> failwith "Invalid API features"
+;;
+
type t = {
name : string;
directory : string;
node_package_manager : Nodejs.Process.npm_user_agent;
syntax_preference : syntax_preference;
+ project_type : project_type;
+ backend_framework : backend_framework option;
+ api_features : api_features option;
bundler : Bundler.t;
is_react_app : bool;
has_tests : bool;
@@ -36,13 +72,18 @@ type t = {
overwrite : overwrite_preference option;
}
-let make ~name ~directory ~syntax_preference ~bundler ~is_react_app ~has_tests
- ~initialize_git ~initialize_npm ~initialize_ocaml_toolchain ~overwrite =
+let make ~name ~directory ~syntax_preference ~project_type ~bundler
+ ~is_react_app ~has_tests ~initialize_git ~initialize_npm
+ ~initialize_ocaml_toolchain ~overwrite ?(backend_framework = None)
+ ?(api_features = None) () =
{
name;
directory;
node_package_manager = Nodejs.Process.npm_config_user_agent;
syntax_preference;
+ project_type;
+ backend_framework;
+ api_features;
bundler;
is_react_app;
has_tests;
@@ -54,18 +95,35 @@ let make ~name ~directory ~syntax_preference ~bundler ~is_react_app ~has_tests
;;
let set_overwrite overwrite config = { config with overwrite = Some overwrite }
+let is_fullstack config = config.project_type = `Fullstack
+let is_frontend_only config = config.project_type = `Frontend
let to_string config =
+ let backend_info =
+ match config.project_type with
+ | `Frontend -> ""
+ | `Fullstack ->
+ Printf.sprintf "Backend framework: %s\nAPI features: %s\n"
+ (config.backend_framework
+ |> Option.map backend_framework_to_string
+ |> Option.value ~default:"None")
+ (config.api_features
+ |> Option.map api_features_to_string
+ |> Option.value ~default:"None")
+ in
Printf.sprintf
"Name: %s\n\
Directory: %s\n\
- Syntax preference: %s\n\
- Bunder: %s\n\
+ Project type: %s\n\
+ %sSyntax preference: %s\n\
+ Bundler: %s\n\
is_react_app: %b\n\
Initialize git: %b\n\
Initialize npm: %b\n\
Initialize OCaml toolchain: %b\n"
config.name config.directory
+ (project_type_to_string config.project_type)
+ backend_info
(syntax_preference_to_string config.syntax_preference)
(Bundler.to_string config.bundler)
config.is_react_app config.initialize_git config.initialize_npm
@@ -83,6 +141,18 @@ let to_json (configuration : t) =
Js.Dict.set dict "syntax_preference"
(Js.Json.string
(syntax_preference_to_string configuration.syntax_preference));
+ Js.Dict.set dict "project_type"
+ (Js.Json.string (project_type_to_string configuration.project_type));
+ (match configuration.backend_framework with
+ | Some framework ->
+ Js.Dict.set dict "backend_framework"
+ (Js.Json.string (backend_framework_to_string framework))
+ | None -> ());
+ (match configuration.api_features with
+ | Some features ->
+ Js.Dict.set dict "api_features"
+ (Js.Json.string (api_features_to_string features))
+ | None -> ());
Js.Dict.set dict "bundler"
(Js.Json.string
(Bundler.to_string configuration.bundler |> String.capitalize_ascii));
@@ -106,6 +176,9 @@ type partial = {
name : string option;
directory : string option;
syntax_preference : syntax_preference option;
+ project_type : project_type option;
+ backend_framework : backend_framework option;
+ api_features : api_features option;
bundler : Bundler.t option;
is_react_app : bool option;
has_tests : bool option;
@@ -114,12 +187,16 @@ type partial = {
initialize_ocaml_toolchain : bool option;
}
-let make_partial ?name ?directory ?syntax_preference ?bundler ?is_react_app
- ?has_tests ?initialize_git ?initialize_npm ?initialize_ocaml_toolchain () =
+let make_partial ?name ?directory ?syntax_preference ?project_type
+ ?backend_framework ?api_features ?bundler ?is_react_app ?has_tests
+ ?initialize_git ?initialize_npm ?initialize_ocaml_toolchain () =
{
name;
directory;
syntax_preference;
+ project_type;
+ backend_framework;
+ api_features;
bundler;
is_react_app;
has_tests;
diff --git a/src/core/dune.ml b/src/core/dune.ml
index 5100535..f007fd3 100644
--- a/src/core/dune.ml
+++ b/src/core/dune.ml
@@ -98,6 +98,10 @@ module Dune_project = struct
let make ?version name = { name; version }
end
+ let backend_dependencies =
+ [ Dependency.make "dream"; Dependency.make "lwt"; Dependency.make "yojson" ]
+ ;;
+
let default_dependencies =
[
Dependency.make ~version:">= 5.1.1" "ocaml";
@@ -149,10 +153,23 @@ module Dune_project = struct
Js.Json.object_ dict
;;
- let template ~project_name ~project_directory ~is_mlx =
+ let make_with_backend_deps ~name ~is_fullstack =
+ let base_deps : Dependency.t String_map.t = default_dependencies in
+ let deps =
+ if is_fullstack then
+ List.fold_left
+ (fun acc (dep : Dependency.t) -> String_map.add dep.name dep acc)
+ base_deps backend_dependencies
+ else base_deps
+ in
+ { name; depends = deps; is_mlx = false }
+ ;;
+
+ let template ~project_name ~project_directory ~is_mlx:_
+ ?(is_fullstack = false) () =
let template_directory = Node.Path.join [| project_directory; "./" |] in
Template.make ~name:"dune-project.tmpl"
- ~value:{ empty with name = project_name; is_mlx }
+ ~value:(make_with_backend_deps ~name:project_name ~is_fullstack)
~dir:template_directory ~to_json
;;
end
diff --git a/src/core/engine.ml b/src/core/engine.ml
index c1ce969..30f0628 100644
--- a/src/core/engine.ml
+++ b/src/core/engine.ml
@@ -93,6 +93,11 @@ let extend_dune_project_with_app_settings ~(is_react_app : bool)
(Dune.Dune_project.add_dependencies React.Dune_project.dependencies)
;;
+let copy_backend_files ~backend_framework ~project_name project_directory =
+ let open Backend_files in
+ Copy.exec { project_directory; backend_framework; project_name }
+;;
+
let copy_test_files ~syntax_preference ~is_react_app project_directory =
let open Test_files in
Copy.exec { project_directory; syntax_preference; is_react_app }
@@ -118,6 +123,43 @@ let extend_dune_project_with_tests ~(is_react_app : bool)
(if is_react_app then Fest.Dune_project.react_dependencies else []))
;;
+let compile_backend_templates ~project_name ~project_directory =
+ let open Promise_result.Syntax.Let in
+ let backend_data = Js.Dict.empty () in
+ Js.Dict.set backend_data "name" (Js.Json.string project_name);
+ let backend_json = Js.Json.object_ backend_data in
+
+ let main_ml_template =
+ Template.make ~name:"main.ml.tmpl" ~value:backend_json
+ ~dir:(Node.Path.join [| project_directory; "backend"; "bin" |])
+ ~to_json:(fun x -> x)
+ in
+ let+ _ = Template.compile main_ml_template in
+
+ let bin_dune_template =
+ Template.make ~name:"dune.tmpl" ~value:backend_json
+ ~dir:(Node.Path.join [| project_directory; "backend"; "bin" |])
+ ~to_json:(fun x -> x)
+ in
+ let+ _ = Template.compile bin_dune_template in
+
+ let backend_dune_template =
+ Template.make ~name:"dune.tmpl" ~value:backend_json
+ ~dir:(Node.Path.join [| project_directory; "backend" |])
+ ~to_json:(fun x -> x)
+ in
+ let+ _ = Template.compile backend_dune_template in
+
+ let readme_template =
+ Template.make ~name:"README.md.tmpl" ~value:backend_json
+ ~dir:(Node.Path.join [| project_directory; "backend" |])
+ ~to_json:(fun x -> x)
+ in
+ let+ _ = Template.compile readme_template in
+
+ Promise_result.resolve_ok ()
+;;
+
let compile = Template.compile
let node_pkg_manager_install = Npm.Install.exec
let copy_git_ignore = Git_scm.Copy_gitignore.exec
diff --git a/src/core/react.ml b/src/core/react.ml
index 2adc21c..122927f 100644
--- a/src/core/react.ml
+++ b/src/core/react.ml
@@ -18,7 +18,7 @@ module Dune_project = struct
Dependency.make "reason-react-ppx";
]
;;
-
+
let mlx_dependencies =
[
Dependency.make "mlx";
diff --git a/src/core/webpack.ml b/src/core/webpack.ml
index 623253d..d55bfb8 100644
--- a/src/core/webpack.ml
+++ b/src/core/webpack.ml
@@ -66,7 +66,9 @@ module Copy_index_html :
;;
let exec (project_dir_name : input) =
- let dest = Node.Path.join [| project_dir_name; "/"; "public"; "/"; "index.html" |] in
+ let dest =
+ Node.Path.join [| project_dir_name; "/"; "public"; "/"; "index.html" |]
+ in
Fs.copy_file ~dest webpack_public_dir_path
|> Promise_result.log_and_map_error (Fun.const error_message)
;;
diff --git a/src/init/scaffold.re b/src/init/scaffold.re
index c4967fb..eba5beb 100644
--- a/src/init/scaffold.re
+++ b/src/init/scaffold.re
@@ -9,9 +9,11 @@ type step =
| Initialize_bundler
// Section 3 - Initialize app files
| Initialize_app_files
- // Section 4 - Initialize test files
+ // Section 4 - Initialize backend files (fullstack only)
+ | Initialize_backend_files
+ // Section 5 - Initialize test files
| Initialize_test_files
- // Section 5 - Compile templates
+ // Section 6 - Compile templates
| Compile_templates
// Section 6 - Optional - Initialize node package manager
| Node_pkg_manager_install
@@ -33,18 +35,19 @@ let step_to_int = step =>
| Create_base_project => 0
| Initialize_bundler => 1
| Initialize_app_files => 2
- | Initialize_test_files => 3
- | Compile_templates => 4
- | Node_pkg_manager_install => 5
- | Opam_update => 6
- | Opam_create_switch => 7
- | Opam_install_dune => 8
- | Dune_install => 9
- | Opam_install_dev_deps => 10
- | Opam_install_deps => 11
- | Dune_build => 12
- | Initialize_git => 13
- | Finished => 14
+ | Initialize_backend_files => 3
+ | Initialize_test_files => 4
+ | Compile_templates => 5
+ | Node_pkg_manager_install => 6
+ | Opam_update => 7
+ | Opam_create_switch => 8
+ | Opam_install_dune => 9
+ | Dune_install => 10
+ | Opam_install_dev_deps => 11
+ | Opam_install_deps => 12
+ | Dune_build => 13
+ | Initialize_git => 14
+ | Finished => 15
};
type state = {
@@ -209,6 +212,24 @@ module App_files = {
);
};
+module Backend_files = {
+ module Copy_files = {
+ let fn = state =>
+ if (Configuration.is_fullstack(state.configuration)) {
+ state.configuration.directory
+ |> Engine.copy_backend_files(
+ ~backend_framework=state.configuration.backend_framework,
+ ~project_name=state.configuration.name,
+ );
+ } else {
+ Promise_result.resolve_ok();
+ };
+ };
+
+ let initializeBackendFiles = state =>
+ Copy_files.fn(state) |> Promise_result.map(() => state);
+};
+
module Test_files = {
module Copy_files = {
let fn = state =>
@@ -282,6 +303,18 @@ module Compile = {
let fn = state => state.readme |> Engine.compile;
};
+ module Compile_backend_templates = {
+ let fn = state =>
+ if (Configuration.is_fullstack(state.configuration)) {
+ Engine.compile_backend_templates(
+ ~project_name=state.configuration.name,
+ ~project_directory=state.configuration.directory,
+ );
+ } else {
+ Promise_result.resolve_ok();
+ };
+ };
+
let compile = state => {
open Promise_result.Syntax.Let;
@@ -292,6 +325,7 @@ module Compile = {
let+ test_dune_file = Compile_test_dune_file.fn(state);
let+ app_module = Compile_app_module.fn(state);
let+ readme = Compile_readme.fn(state);
+ let+ _ = Compile_backend_templates.fn(state);
Promise_result.resolve_ok({
...state,
@@ -380,6 +414,8 @@ let make = (~configuration: Configuration.t, ~onComplete) => {
configuration.syntax_preference == `OCaml
&& configuration.is_react_app;
},
+ ~is_fullstack=Configuration.is_fullstack(configuration),
+ (),
),
root_dune_file:
Dune.Dune_file.template(
@@ -472,11 +508,24 @@ let make = (~configuration: Configuration.t, ~onComplete) => {
loadingLabel="Copying application files..."
successLabel={j|✔ Successfully copied application files!|j}
onComplete={goToNextStepWithNewState(
- configuration.has_tests ? Initialize_test_files : Compile_templates,
+ Configuration.is_fullstack(configuration)
+ ? Initialize_backend_files
+ : configuration.has_tests ? Initialize_test_files : Compile_templates,
)}
onError
fn={() => App_files.copyApplicationFiles(state)}
/>
+ Backend_files.initializeBackendFiles(state)}
+ />
{
+ let onChange =
+ React.useCallback1(
+ (value: string) => {
+ onSubmit(Core.Configuration.project_type_of_string(value))
+ },
+ [|onSubmit|],
+ );
+
+
+ {React.string("What type of project would you like to create?")}
+
+
+ ;
+ };
+};
+
+module Backend_framework = {
+ let options: array(Ui.Select.select_option) = [|
+ Ui.Select.{
+ value: "dream",
+ label: "Dream (modern, batteries-included web framework)",
+ },
+ |];
+
+ [@react.component]
+ let make = (~onSubmit, ~isDisabled) => {
+ let onChange =
+ React.useCallback1(
+ (value: string) => {
+ onSubmit(Core.Configuration.backend_framework_of_string(value))
+ },
+ [|onSubmit|],
+ );
+
+
+ {React.string("Which backend framework would you like to use?")}
+
+
+ ;
+ };
+};
+
+module API_features = {
+ let options: array(Ui.Select.select_option) = [|
+ Ui.Select.{
+ value: "rest",
+ label: "REST endpoints (JSON API)",
+ },
+ |];
+
+ [@react.component]
+ let make = (~onSubmit, ~isDisabled) => {
+ let onChange =
+ React.useCallback1(
+ (value: string) => {
+ onSubmit(Core.Configuration.api_features_of_string(value))
+ },
+ [|onSubmit|],
+ );
+
+
+ {React.string("What API features would you like to include?")}
+
+
+ ;
+ };
+};
+
type step =
| Name
| Syntax_preference
+ | Project_type
+ | Backend_framework
+ | API_features
| React_app
| Tests
| Bundler
@@ -376,6 +461,12 @@ let make =
React.useState(() => initial_configuration.directory);
let (syntax_preference, set_syntax_preference) =
React.useState((): option(Configuration.syntax_preference) => None);
+ let (project_type, set_project_type) =
+ React.useState((): option(Configuration.project_type) => None);
+ let (backend_framework, set_backend_framework) =
+ React.useState((): option(Configuration.backend_framework) => None);
+ let (api_features, set_api_features) =
+ React.useState((): option(Configuration.api_features) => None);
let (is_react_app, set_is_react_app) =
React.useState((): option(bool) => None);
let (has_tests, set_has_tests) = React.useState((): option(bool) => None);
@@ -413,6 +504,39 @@ let make =
(syntax_preference: Configuration.syntax_preference) =>
if (active_step == Syntax_preference) {
set_syntax_preference(_ => Some(syntax_preference));
+ set_active_step(_ => Project_type);
+ },
+ [|active_step|],
+ );
+
+ let onSubmitProjectType =
+ React.useCallback1(
+ (project_type: Configuration.project_type) =>
+ if (active_step == Project_type) {
+ set_project_type(_ => Some(project_type));
+ switch (project_type) {
+ | `Frontend => set_active_step(_ => React_app)
+ | `Fullstack => set_active_step(_ => Backend_framework)
+ };
+ },
+ [|active_step|],
+ );
+
+ let onSubmitBackendFramework =
+ React.useCallback1(
+ (framework: Configuration.backend_framework) =>
+ if (active_step == Backend_framework) {
+ set_backend_framework(_ => Some(framework));
+ set_active_step(_ => API_features);
+ },
+ [|active_step|],
+ );
+
+ let onSubmitAPIFeatures =
+ React.useCallback1(
+ (features: Configuration.api_features) =>
+ if (active_step == API_features) {
+ set_api_features(_ => Some(features));
set_active_step(_ => React_app);
},
[|active_step|],
@@ -514,6 +638,9 @@ let make =
~syntax_preference={
Option.get(syntax_preference);
},
+ ~project_type={
+ Option.get(project_type);
+ },
~bundler={
Option.get(bundler);
},
@@ -533,6 +660,9 @@ let make =
~overwrite={
overwrite_preference;
},
+ ~backend_framework,
+ ~api_features,
+ (),
),
);
};
@@ -544,7 +674,15 @@ let make =
let show_name_step = Option.is_none(initial_configuration.name);
let show_syntax_preference_step = Option.is_some(name);
- let show_react_step = Option.is_some(syntax_preference);
+ let show_project_type_step = Option.is_some(syntax_preference);
+ let show_backend_framework_step =
+ Option.is_some(project_type)
+ && Option.map(pt => pt == `Fullstack, project_type) == Some(true);
+ let show_api_features_step = Option.is_some(backend_framework);
+ let show_react_step =
+ Option.is_some(project_type)
+ && Option.map(pt => pt == `Frontend, project_type) == Some(true)
+ || Option.is_some(api_features);
let show_add_tests_step = Option.is_some(is_react_app);
let show_bundler_step = Option.is_some(has_tests);
let show_git_step = Option.is_some(bundler) && should_prompt_git;
@@ -571,6 +709,24 @@ let make =
isDisabled={active_step != Syntax_preference}
/>
+
+
+
+
+
+
+
+
+