From d4cbb536e7aa9e80f2029087798351f93fa9f838 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Mon, 19 Apr 2021 22:24:13 -0700 Subject: [PATCH] feature: sfc.gen.init WIP factored out common code between sfc.gen.component/init implemented injection TODO: come up with a demo component and templatize refs #2 --- lib/mix/sfc_gen_live.ex | 49 +++++++ lib/mix/tasks/sfc.gen.component.ex | 53 +------- lib/mix/tasks/sfc.gen.init.ex | 125 ++++++++++++++++++ priv/templates/sfc.gen.init/demo.ex | 21 +++ priv/templates/sfc.gen.init/demo.sface | 0 test/mix/tasks/sfc.gen.init_test.exs | 12 ++ ...en_live_test.exs => sfc.gen.live.test.exs} | 2 +- todos/main.todo | 2 + 8 files changed, 217 insertions(+), 47 deletions(-) create mode 100644 lib/mix/tasks/sfc.gen.init.ex create mode 100644 priv/templates/sfc.gen.init/demo.ex create mode 100644 priv/templates/sfc.gen.init/demo.sface create mode 100644 test/mix/tasks/sfc.gen.init_test.exs rename test/mix/tasks/{sfc_gen_live_test.exs => sfc.gen.live.test.exs} (99%) diff --git a/lib/mix/sfc_gen_live.ex b/lib/mix/sfc_gen_live.ex index a167b4b..88eaa0b 100644 --- a/lib/mix/sfc_gen_live.ex +++ b/lib/mix/sfc_gen_live.ex @@ -10,4 +10,53 @@ defmodule Mix.SfcGenLive do def generator_paths do [".", :sfc_gen_live, :phoenix] end + + # this + def put_context_app(opts, nil) do + Keyword.put(opts, :context_app, Mix.Phoenix.context_app()) + end + + def put_context_app(opts, string) do + Keyword.put(opts, :context_app, String.to_atom(string)) + end + + def valid_namespace?(name) when is_binary(name) do + name + |> split_name() + |> valid_namespace?() + end + + def valid_namespace?(namespace_parts) when is_list(namespace_parts) do + Enum.all?(namespace_parts, &valid_module?/1) + end + + def split_name(name) do + name + |> Phoenix.Naming.underscore() + |> String.split("/", trim: true) + end + + def inflect(namespace_parts, name_parts) do + path = Enum.concat(namespace_parts, name_parts) |> Enum.join("/") + base = Module.concat([Mix.Phoenix.base()]) + web_module = base |> Mix.Phoenix.web_module() + scoped = path |> Phoenix.Naming.camelize() + namespace_module = Module.concat(namespace_parts |> Enum.map(&Phoenix.Naming.camelize/1)) + module = Module.concat(web_module, scoped) + alias = Module.concat([Module.split(module) |> List.last()]) + human = Enum.map(name_parts, &Phoenix.Naming.humanize/1) |> Enum.join(" ") + + [ + alias: alias, + human: human, + web_module: web_module, + namespace_module: namespace_module, + module: module, + path: path + ] + end + + defp valid_module?(name) do + Phoenix.Naming.camelize(name) =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/ + end end diff --git a/lib/mix/tasks/sfc.gen.component.ex b/lib/mix/tasks/sfc.gen.component.ex index 38ecfe4..434bda7 100644 --- a/lib/mix/tasks/sfc.gen.component.ex +++ b/lib/mix/tasks/sfc.gen.component.ex @@ -6,7 +6,7 @@ defmodule Mix.Tasks.Sfc.Gen.Component do alias Mix.Surface.Component.{Props, Slots} - @switches [template: :boolean, namespace: :string, slot: [:string, :keep]] + @switches [template: :boolean, namespace: :string, slot: [:string, :keep], context_app: :string] @default_opts [template: false, namespace: "components"] @doc false def run(args) do @@ -19,7 +19,7 @@ defmodule Mix.Tasks.Sfc.Gen.Component do slots = slots |> Slots.parse() assigns = - inflect(namespace_parts, name_parts) + Mix.SfcGenLive.inflect(namespace_parts, name_parts) |> Keyword.put(:props, props) |> Keyword.put(:template, opts[:template]) |> Keyword.put(:slots, slots) @@ -48,15 +48,14 @@ defmodule Mix.Tasks.Sfc.Gen.Component do # def build_component(args) do # end - @spec validate_args!(String.t(), String.t()) :: {[...], [...]} defp validate_args!(name, namespace) do {namespace_parts, name_parts} = normalized_component_name(name, namespace) cond do - not Enum.all?(name_parts, &valid_module?/1) -> + not SfcGenLive.valid_namespace?(name_parts) -> raise_with_help("Expected the component, #{inspect(name)}, to be a valid module name") - not Enum.all?(namespace_parts, &valid_module?/1) -> + not SfcGenLive.valid_namespace?(namespace_parts) -> raise_with_help( "Expected the namespace, #{inspect(namespace)}, to be a valid module name" ) @@ -134,30 +133,16 @@ defmodule Mix.Tasks.Sfc.Gen.Component do merged_opts = @default_opts |> Keyword.merge(opts) - |> put_context_app(opts[:context_app]) + |> Mix.SfcGenLive.put_context_app(opts[:context_app]) [name | props] = parsed {merged_opts, slots, name, props} end - defp put_context_app(opts, nil) do - Keyword.put(opts, :context_app, Mix.Phoenix.otp_app()) - end - - defp put_context_app(opts, string) do - Keyword.put(opts, :context_app, String.to_atom(string)) - end - - defp split_name(name) do - name - |> Phoenix.Naming.underscore() - |> String.split("/", trim: true) - end - @spec normalized_component_name(String.t(), String.t()) :: {[String.t()], [String.t()]} defp normalized_component_name(name, namespace) do - namespace_parts = split_name(namespace) - name_parts = split_name(name) |> strip_namespace(namespace_parts) + namespace_parts = Mix.SfcGenLive.split_name(namespace) + name_parts = Mix.SfcGenLive.split_name(name) |> strip_namespace(namespace_parts) {namespace_parts, name_parts} end @@ -170,28 +155,4 @@ defmodule Mix.Tasks.Sfc.Gen.Component do name_parts end end - - defp valid_module?(name) do - Phoenix.Naming.camelize(name) =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/ - end - - defp inflect(namespace_parts, name_parts) do - path = Enum.concat(namespace_parts, name_parts) |> Enum.join("/") - base = Module.concat([Mix.Phoenix.base()]) - web_module = base |> Mix.Phoenix.web_module() - scoped = path |> Phoenix.Naming.camelize() - namespace_module = Module.concat(namespace_parts |> Enum.map(&Phoenix.Naming.camelize/1)) - module = Module.concat(web_module, scoped) - alias = Module.concat([Module.split(module) |> List.last()]) - human = Enum.map(name_parts, &Phoenix.Naming.humanize/1) |> Enum.join(" ") - - [ - alias: alias, - human: human, - web_module: web_module, - namespace_module: namespace_module, - module: module, - path: path - ] - end end diff --git a/lib/mix/tasks/sfc.gen.init.ex b/lib/mix/tasks/sfc.gen.init.ex new file mode 100644 index 0000000..dc24ed9 --- /dev/null +++ b/lib/mix/tasks/sfc.gen.init.ex @@ -0,0 +1,125 @@ +defmodule Mix.Tasks.Sfc.Gen.Init do + @moduledoc """ + Generates a Surface component. + """ + use Mix.Task + + @switches [template: :boolean, namespace: :string, demo: :boolean, context_app: :string] + @default_opts [template: true, namespace: "components", demo: true] + @aliases [t: :template, n: :namespace, d: :demo] + @doc false + def run(args) do + opts = parse_opts(args) + + namespace_parts = validate_namespace!(opts[:namespace]) + + assigns = Mix.SfcGenLive.inflect(namespace_parts, "counter") + + maybe_include_demo(opts, assigns) + end + + defp parse_opts(args) do + {opts, _parsed} = + OptionParser.parse!(args, + strict: @switches, + aliases: @aliases + ) + + merged_opts = + @default_opts + |> Keyword.merge(opts) + |> Mix.SfcGenLive.put_context_app(opts[:context_app]) + + merged_opts + end + + defp validate_namespace!(namespace) do + cond do + not Mix.SfcGenLive.valid_namespace?(namespace) -> + raise_with_help( + "Expected the namespace, #{inspect(namespace)}, to be a valid module name" + ) + + true -> + namespace + end + end + + @spec raise_with_help(String.t()) :: no_return() + defp raise_with_help(msg) do + Mix.raise(""" + #{msg} + + mix sfc.gen.init takes + + - a `--demo` option, default true, that controls whether + a demo component will be generated in the app. + + - a `--template` boolean option, default true, which specifies whether the + demo component template will be in a `.sface` file or in a `~H` sigil in + the component module + + - an optional `--namespace` option that is a relative path + in `lib/my_app_web` where the demo component will be created. The default + value is `components`. The `--namespace` option is ignored if + `--demo false` is passed. + + + For example: + + mix sfc.gen.init --namespace my_components + + will create `lib/my_app_web/my_components/counter.ex` and `lib/my_app_web/my_components/counter.sface` + """) + end + + defp maybe_include_demo(opts, assigns) do + if opts[:demo] do + web_dir = Mix.Phoenix.web_path(opts[:context_app]) + paths = Mix.SfcGenLive.generator_paths() + + files = [ + {:eex, "demo.ex", Path.join(web_dir, "#{assigns[:path]}.ex")} + ] + + template_files = [ + {:eex, "demo.sface", Path.join(web_dir, "#{assigns[:path]}.sface")} + ] + + Mix.Phoenix.copy_from(paths, "priv/templates/sfc.gen.init", assigns, files) + + if opts[:template] do + Mix.Phoenix.copy_from(paths, "priv/templates/sfc.gen.init", assigns, template_files) + end + end + end + + def inject_in_formatter_exs do + file_path = ".formatter.exs" + file = File.read!(file_path) + + unless Regex.match?(~r/import_deps:[^]]+:surface/, file) do + Mix.shell().info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)]) + String.replace(file, ~r/(import_deps:\s*\[[^]]+)\]/, "\\1, :surface]") + File.write!(file_path, file) + end + end + + def inject_live_reload_config(web_dir) do + file_path = "config/dev.exs" + file = File.read!(file_path) + + unless Regex.match?(~r/live_reload:[^]]+\(sface\)/s, file) do + Mix.shell().info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)]) + + file = + String.replace( + file, + ~r/(live_reload: .*\n)( *~r)([^]]+")(\s*)\]/s, + "\\1\\2\\3,\n\\2\"#{web_dir}/live/.*(sface)$\"\\4]" + ) + + File.write!(file_path, file) + end + end +end diff --git a/priv/templates/sfc.gen.init/demo.ex b/priv/templates/sfc.gen.init/demo.ex new file mode 100644 index 0000000..0fd596e --- /dev/null +++ b/priv/templates/sfc.gen.init/demo.ex @@ -0,0 +1,21 @@ +defmodule <%= inspect module %> do + @moduledoc """ + Document <%= inspect module %> here. + """ + use Surface.Component +<%= for {name, prop} <- props do %> + @doc "property <%= name %>" + prop <%= name %>, <%= inspect prop.type %><%= Enum.join(prop.opts, ", ") %> +<% end %><%= for slot <- slots do %> + @doc "property <%= slot.name %>" + slot <%= slot.name %><%= Enum.join(slot.opts, ", ") %> +<% end %><%= unless template do %> + + def render(assigns) do + ~H""" + <%= for slot <- slots do %> + + <%= slot.attr_props %>/><% end %> + """ + end +<% end %>end diff --git a/priv/templates/sfc.gen.init/demo.sface b/priv/templates/sfc.gen.init/demo.sface new file mode 100644 index 0000000..e69de29 diff --git a/test/mix/tasks/sfc.gen.init_test.exs b/test/mix/tasks/sfc.gen.init_test.exs new file mode 100644 index 0000000..d342e92 --- /dev/null +++ b/test/mix/tasks/sfc.gen.init_test.exs @@ -0,0 +1,12 @@ +Code.require_file("../../mix_helper.exs", __DIR__) + +defmodule Mix.Tasks.Sfc.Gen.InitTest do + use ExUnit.Case + import MixHelper + alias Mix.Tasks.Sfc.Gen + + setup do + Mix.Task.clear() + :ok + end +end diff --git a/test/mix/tasks/sfc_gen_live_test.exs b/test/mix/tasks/sfc.gen.live.test.exs similarity index 99% rename from test/mix/tasks/sfc_gen_live_test.exs rename to test/mix/tasks/sfc.gen.live.test.exs index 1b510a3..cabbd54 100644 --- a/test/mix/tasks/sfc_gen_live_test.exs +++ b/test/mix/tasks/sfc.gen.live.test.exs @@ -1,6 +1,6 @@ Code.require_file("../../mix_helper.exs", __DIR__) -defmodule Mix.Tasks.Phx.Gen.LiveTest do +defmodule Mix.Tasks.Sfc.Gen.LiveTest do use ExUnit.Case import MixHelper alias Mix.Tasks.Sfc.Gen diff --git a/todos/main.todo b/todos/main.todo index c227b2c..f4a4487 100644 --- a/todos/main.todo +++ b/todos/main.todo @@ -56,6 +56,8 @@ sfc.gen.live: `git reset --hard HEAD; git clean -fd; mix deps.get; mix deps.compile; mix ecto.reset; MIX_ENV=test mix ecto.reset` ☐ write blog post showing & explaining the generated pieces +sfc.gen.init: + phx.gen.live: ☐ report bug with `mix phx.gen.live Blog Post posts title alarm:time` ☐ check if mix sfc.gen.live Blog Post posts title tags:array:text results in failing test OOB