defmodule Mix.Tasks.Phia.Install do @moduledoc """ Sets up PhiaUI in the current Phoenix project. This is the first command to run when integrating PhiaUI into an existing Phoenix application. It performs three idempotent setup steps: 1. **Injects the theme CSS** — Prepends the PhiaUI TailwindCSS v4 `@theme` block (color tokens, radius, typography, etc.) into `assets/css/app.css`. 2. **Creates the hooks entry point** — Writes `assets/js/phia_hooks/index.js` with an empty `PhiaHooks` export that you can add individual component hooks to. 3. **Wires hooks into app.js** — Prepends an import statement and the `PhiaHooks` registration snippet to `assets/js/app.js`. ## Usage mix phia.install Run this command once from the root of your Phoenix project. It is safe to run multiple times — each step is idempotent and skips silently when already applied. ## What gets modified assets/css/app.css — PhiaUI @theme block prepended assets/js/phia_hooks/ — directory created assets/js/phia_hooks/index.js — PhiaHooks export created assets/js/app.js — import + hook registration prepended ## Typical workflow # 1. Install PhiaUI setup $ mix phia.install # 2. Eject individual components as editable source files $ mix phia.add button $ mix phia.add dialog # 3. Optionally install theme presets for runtime colour switching $ mix phia.theme install ## Idempotency markers The task uses sentinel strings to detect prior installation: - CSS: `/* PhiaUI Theme — do not remove this line */` - JS: `// phia_hooks_registered` Do not remove these markers, as they prevent double-injection. ## Options --help Print this help message """ use Mix.Task @shortdoc "Installs PhiaUI theme tokens, hooks, and app.js wiring" # Sentinel string written at the top of app.css to detect prior installation. @css_marker "/* PhiaUI Theme — do not remove this line */" # Sentinel string written at the top of app.js to detect prior installation. @js_marker "// phia_hooks_registered" @hooks_content """ // PhiaUI JS Hooks — auto-generated by mix phia.install // Add your custom PhiaUI hooks here or import from sub-modules. const PhiaHooks = {} export default PhiaHooks """ @impl Mix.Task def run(["--help"]) do Mix.shell().info(@moduledoc) end def run(_args) do name = app_name() root = File.cwd!() css_path = Path.join([root, "assets/css/app.css"]) if File.exists?(css_path) do inject_theme(css_path) Mix.shell().info([:green, "* inject ", :reset, css_path]) else Mix.shell().error("Could not find #{css_path}. Run from your Phoenix project root.") end inject_hooks(root) Mix.shell().info([:green, "* create ", :reset, "assets/js/phia_hooks/index.js"]) inject_app_js(root) Mix.shell().info([:green, "* update ", :reset, "assets/js/app.js"]) Mix.shell().info([:green, "✓ PhiaUI installed for ", :reset, inspect(name)]) end @doc """ Returns the current Mix project's application name atom. ## Example iex> Mix.Tasks.Phia.Install.app_name() :phia_ui """ @spec app_name() :: atom() def app_name, do: Mix.Project.config()[:app] @doc """ Injects the PhiaUI `@theme` block into the CSS file at `path`. The theme block is read from `priv/templates/theme/theme.css` and prepended to the existing file contents, separated by the idempotency marker comment. Skips silently if the marker is already present, so it is safe to call this function multiple times. ## Return values - `:ok` — theme block was injected. - `:already_installed` — marker was already present; file was not changed. """ @spec inject_theme(Path.t()) :: :ok | :already_installed def inject_theme(path) do current = File.read!(path) if String.contains?(current, @css_marker) do :already_installed else theme_block = build_theme_block() File.write!(path, theme_block <> current) :ok end end @doc """ Creates `assets/js/phia_hooks/index.js` under `root` with an empty `PhiaHooks` export. Uses `Mix.Generator.create_file/3` for interactive conflict handling: if the file already exists, the user is prompted before any overwrite. The generated file exports an empty object that you populate by importing individual component hook files, for example: import PhiaDialog from "./dialog.js" const PhiaHooks = { PhiaDialog } export default PhiaHooks """ @spec inject_hooks(Path.t()) :: :ok | nil def inject_hooks(root) do hooks_dir = Path.join([root, "assets", "js", "phia_hooks"]) hooks_path = Path.join(hooks_dir, "index.js") File.mkdir_p!(hooks_dir) Mix.Generator.create_file(hooks_path, @hooks_content) end @doc """ Adds a PhiaHooks import and registration snippet to `assets/js/app.js` under `root`. The snippet is prepended to the file so the hooks are available before any LiveSocket initialisation code. Skips if the idempotency marker is already present. ## Return values - `:ok` — snippet was prepended to app.js. - `:already_installed` — marker was already present; file was not changed. - `:not_found` — `assets/js/app.js` does not exist. """ @spec inject_app_js(Path.t()) :: :ok | :already_installed | :not_found def inject_app_js(root) do app_js = Path.join([root, "assets", "js", "app.js"]) if File.exists?(app_js) do current = File.read!(app_js) if String.contains?(current, @js_marker) do :already_installed else injection = "#{@js_marker}\n" <> "import PhiaHooks from './phia_hooks/index.js'\n\n" File.write!(app_js, injection <> current) :ok end else :not_found end end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- # Reads the theme CSS template and wraps it with the idempotency marker. # The marker must appear at the very top so `String.contains?/2` finds it # even if the file has been reformatted. defp build_theme_block do template_path = template_path() theme_css = File.read!(template_path) @css_marker <> "\n" <> theme_css <> "\n" end # Resolves the theme CSS template path. # Prefers a local priv/ path (useful when developing phia_ui itself) and # falls back to the installed application's priv directory. defp template_path do local = Path.join([File.cwd!(), "priv/templates/theme/theme.css"]) if File.exists?(local) do local else Application.app_dir(:phia_ui, "priv/templates/theme/theme.css") end end end