defmodule Mix.Tasks.Phx.Install.Html do @shortdoc "Adds HTML rendering support" @moduledoc """ Adds HTML rendering support to a Phoenix application. This task sets up: - `phoenix_html` dependency - `lib/_web/components/core_components.ex` - Core UI components - `lib/_web/components/layouts.ex` - Layout component module - `lib/_web/components/layouts/root.html.heex` - Root HTML layout - `lib/_web/components/layouts/app.html.heex` - App layout - `lib/_web/controllers/error_html.ex` - HTML error rendering - Updates web module with `html` and `html_helpers` functions ## Usage mix phx.install.html This task is typically called by `mix phx.install` rather than directly. """ use Igniter.Mix.Task @impl Igniter.Mix.Task def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ group: :phoenix, example: "mix phx.install.html", adds_deps: [{:phoenix_html, "~> 4.1"}] } end @impl Igniter.Mix.Task def igniter(igniter) do app_name = Igniter.Project.Application.app_name(igniter) web_module = Igniter.Libs.Phoenix.web_module(igniter) endpoint_module = Module.concat(web_module, Endpoint) igniter |> Igniter.Project.Deps.add_dep({:phoenix, "~> 1.7"}) |> Igniter.Project.Deps.add_dep({:phoenix_html, "~> 4.1"}) |> Igniter.Project.IgniterConfig.add_extension(Igniter.Extensions.Phoenix) |> create_core_components(web_module) |> create_layouts_module(web_module) |> create_root_layout(web_module) |> create_app_layout(web_module) |> create_error_html(web_module) |> add_html_helpers_to_web_module(web_module, endpoint_module) |> add_browser_pipeline_to_router(web_module) |> update_endpoint_error_config(app_name, endpoint_module, web_module) end defp create_core_components(igniter, web_module) do core_components_module = Module.concat(web_module, CoreComponents) Igniter.Project.Module.find_and_update_or_create_module( igniter, core_components_module, """ @moduledoc \"\"\" Provides core UI components. The components in this module use function components and can be used in both regular views and LiveView. \"\"\" use Phoenix.Component @doc \"\"\" Renders flash notices. ## Examples <.flash kind={:info} flash={@flash} /> \"\"\" attr :id, :string, doc: "the optional id of flash container" attr :flash, :map, default: %{}, doc: "the map of flash messages to display" attr :title, :string, default: nil attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" slot :inner_block, doc: "the optional inner block that renders the flash message" def flash(assigns) do assigns = assign_new(assigns, :id, fn -> "flash-\#{assigns.kind}" end) ~H\"\"\" \"\"\" end @doc \"\"\" Shows the flash group with standard titles and content. ## Examples <.flash_group flash={@flash} /> \"\"\" attr :flash, :map, required: true, doc: "the map of flash messages" attr :id, :string, default: "flash-group", doc: "the optional id of flash container" def flash_group(assigns) do ~H\"\"\"
<.flash kind={:info} flash={@flash} /> <.flash kind={:error} flash={@flash} />
\"\"\" end @doc \"\"\" Renders a simple form. ## Examples <.simple_form for={@form} phx-change="validate" phx-submit="save"> <.input field={@form[:email]} label="Email"/> <:actions> <.button>Save \"\"\" attr :for, :any, required: true, doc: "the data structure for the form" attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" attr :rest, :global, include: ~w(autocomplete name rel action enctype method novalidate target multipart), doc: "the arbitrary HTML attributes to apply to the form tag" slot :inner_block, required: true slot :actions, doc: "the slot for form actions, such as a submit button" def simple_form(assigns) do ~H\"\"\" <.form :let={f} for={@for} as={@as} {@rest}> {render_slot(@inner_block, f)}
{render_slot(action, f)}
\"\"\" end @doc \"\"\" Renders a button with navigation support. ## Examples <.button>Send! <.button phx-click="go" variant="primary">Send! <.button navigate={~p"/"}>Home \"\"\" attr :rest, :global, include: ~w(href navigate patch method download name value disabled form) attr :class, :any attr :variant, :string, values: ~w(primary) slot :inner_block, required: true def button(%{rest: rest} = assigns) do variants = %{"primary" => "button-primary", nil => "button"} assigns = assign_new(assigns, :class, fn -> [Map.fetch!(variants, assigns[:variant])] end) if rest[:href] || rest[:navigate] || rest[:patch] do ~H\"\"\" <.link class={@class} {@rest}> {render_slot(@inner_block)} \"\"\" else ~H\"\"\" \"\"\" end end @doc \"\"\" Renders an input with label and error messages. A `Phoenix.HTML.FormField` may be passed as argument, which is used to retrieve the input name, id, and values. Otherwise all attributes may be passed explicitly. ## Examples <.input field={@form[:email]} type="email" /> <.input name="my-input" errors={["oh no!"]} /> \"\"\" attr :id, :any, default: nil attr :name, :any attr :label, :string, default: nil attr :value, :any attr :type, :string, default: "text", values: ~w(checkbox color date datetime-local email file hidden month number password range search select tel text textarea time url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" attr :errors, :list, default: [] attr :checked, :boolean, doc: "the checked flag for checkbox inputs" attr :prompt, :string, default: nil, doc: "the prompt for select inputs" attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2" attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs" attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength multiple pattern placeholder readonly required rows size step) def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] assigns |> assign(field: nil, id: assigns.id || field.id) |> assign(:errors, Enum.map(errors, &translate_error(&1))) |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end) |> assign_new(:value, fn -> field.value end) |> input() end def input(%{type: "checkbox"} = assigns) do assigns = assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) end) ~H\"\"\"
<.error :for={msg <- @errors}>{msg}
\"\"\" end def input(%{type: "select"} = assigns) do ~H\"\"\"
<.error :for={msg <- @errors}>{msg}
\"\"\" end def input(%{type: "textarea"} = assigns) do ~H\"\"\"
<.error :for={msg <- @errors}>{msg}
\"\"\" end def input(%{type: "hidden"} = assigns) do ~H\"\"\" \"\"\" end def input(assigns) do ~H\"\"\"
<.error :for={msg <- @errors}>{msg}
\"\"\" end defp error(assigns) do ~H\"\"\"

{render_slot(@inner_block)}

\"\"\" end @doc \"\"\" Translates an error message. \"\"\" def translate_error({msg, opts}) do Enum.reduce(opts, msg, fn {key, value}, acc -> String.replace(acc, "%{\#{key}}", fn _ -> to_string(value) end) end) end @doc \"\"\" Translates the errors for a field from a keyword list of errors. \"\"\" def translate_errors(errors, field) when is_list(errors) do for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) end """, fn zipper -> {:ok, zipper} end ) end defp create_layouts_module(igniter, web_module) do layouts_module = Module.concat(web_module, Layouts) Igniter.Project.Module.find_and_update_or_create_module( igniter, layouts_module, """ @moduledoc \"\"\" This module holds different layouts used by your application. See the `layouts` directory for all templates available. \"\"\" use #{inspect(web_module)}, :html embed_templates "layouts/*" """, fn zipper -> {:ok, zipper} end ) end defp create_root_layout(igniter, web_module) do app_module = web_module |> Module.split() |> List.first() |> then(fn name -> String.replace(name, "Web", "") end) layout_content = """ <.live_title default="#{app_module}"> {assigns[:page_title]} {@inner_content} """ web_module_snake = web_module |> Module.split() |> List.last() |> Macro.underscore() path = "lib/#{web_module_snake}/components/layouts/root.html.heex" Igniter.create_new_file(igniter, path, layout_content, on_exists: :skip) end defp create_app_layout(igniter, web_module) do layout_content = """
<.flash_group flash={@flash} /> {@inner_content}
""" web_module_snake = web_module |> Module.split() |> List.last() |> Macro.underscore() path = "lib/#{web_module_snake}/components/layouts/app.html.heex" Igniter.create_new_file(igniter, path, layout_content, on_exists: :skip) end defp create_error_html(igniter, web_module) do error_html_module = Module.concat(web_module, ErrorHTML) Igniter.Project.Module.find_and_update_or_create_module( igniter, error_html_module, """ @moduledoc \"\"\" This module is invoked by your endpoint in case of errors on HTML requests. See config/config.exs. \"\"\" use #{inspect(web_module)}, :html # If you want to customize your error pages, # uncomment the embed_templates/1 call below # and add pages to the error directory: # # * lib/#{Macro.underscore(web_module)}/controllers/error_html/404.html.heex # * lib/#{Macro.underscore(web_module)}/controllers/error_html/500.html.heex # # embed_templates "error_html/*" # The default is to render a plain text page based on # the template name. For example, "404.html" becomes # "Not Found". def render(template, _assigns) do Phoenix.Controller.status_message_from_template(template) end """, fn zipper -> {:ok, zipper} end ) end defp add_html_helpers_to_web_module(igniter, web_module, _endpoint_module) do html_code = """ def html do quote do use Phoenix.Component import Phoenix.Controller, only: [get_csrf_token: 0, view_module: 1, view_template: 1] unquote(html_helpers()) end end """ html_helpers_code = """ defp html_helpers do quote do import Phoenix.HTML import #{inspect(Module.concat(web_module, CoreComponents))} alias Phoenix.LiveView.JS alias #{inspect(Module.concat(web_module, Layouts))} unquote(verified_routes()) end end """ Igniter.Project.Module.find_and_update_module!(igniter, web_module, fn zipper -> zipper = maybe_add_function_before_verified_routes(zipper, :html, 0, html_code) zipper = maybe_add_private_function(zipper, :html_helpers, 0, html_helpers_code) {:ok, zipper} end) end defp maybe_add_function_before_verified_routes(zipper, function_name, arity, code) do case Igniter.Code.Function.move_to_def(zipper, function_name, arity) do {:ok, _} -> zipper :error -> case Igniter.Code.Function.move_to_def(zipper, :verified_routes, 0, target: :at) do {:ok, verified_routes_zipper} -> Igniter.Code.Common.add_code(verified_routes_zipper, code, placement: :before) :error -> Igniter.Code.Common.add_code(zipper, code) end end end defp maybe_add_private_function(zipper, function_name, arity, code) do case Igniter.Code.Function.move_to_defp(zipper, function_name, arity) do {:ok, _} -> zipper :error -> case Igniter.Code.Function.move_to_def(zipper, :verified_routes, 0, target: :at) do {:ok, verified_routes_zipper} -> Igniter.Code.Common.add_code(verified_routes_zipper, code, placement: :after) :error -> Igniter.Code.Common.add_code(zipper, code) end end end defp add_browser_pipeline_to_router(igniter, web_module) do router_module = Module.concat(web_module, Router) layouts_module = Module.concat(web_module, Layouts) browser_pipeline_code = """ pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_flash plug :put_root_layout, html: {#{inspect(layouts_module)}, :root} plug :protect_from_forgery plug :put_secure_browser_headers end """ case Igniter.Project.Module.find_and_update_module( igniter, router_module, &insert_browser_pipeline(&1, browser_pipeline_code) ) do {:ok, igniter} -> igniter {:error, igniter} -> igniter end end defp insert_browser_pipeline(zipper, browser_pipeline_code) do case Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, :pipeline, 2, &Igniter.Code.Function.argument_equals?(&1, 0, :browser) ) do {:ok, _} -> {:ok, zipper} :error -> insert_browser_pipeline_before_api(zipper, browser_pipeline_code) end end defp insert_browser_pipeline_before_api(zipper, browser_pipeline_code) do case Igniter.Code.Function.move_to_function_call_in_current_scope( zipper, :pipeline, 2, &Igniter.Code.Function.argument_equals?(&1, 0, :api) ) do {:ok, api_zipper} -> {:ok, Igniter.Code.Common.add_code(api_zipper, browser_pipeline_code, placement: :before)} :error -> {:ok, Igniter.Code.Common.add_code(zipper, browser_pipeline_code)} end end defp update_endpoint_error_config(igniter, app_name, endpoint_module, web_module) do error_html_module = Module.concat(web_module, ErrorHTML) error_json_module = Module.concat(web_module, ErrorJSON) Igniter.Project.Config.configure( igniter, "config.exs", app_name, [endpoint_module, :render_errors, :formats], [html: error_html_module, json: error_json_module], updater: fn zipper -> {:ok, zipper} end ) end end