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} /> + + + + + + + + +