defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup.Docs do @moduledoc false @spec short_doc() :: String.t() def short_doc do "Convert a Phoenix application to use a Vite + Tanstack DB based frontend" end @spec example() :: String.t() def example do "mix phx.sync.tanstack_db.setup" end @spec long_doc() :: String.t() def long_doc do """ #{short_doc()} This is a very invasive task that does the following: - Removes `esbuild` with `vite` and removes the Elixir integration with tailwindcss - Adds a `package.json` with the required dependencies for `@tanstack/db`, `@tanstack/router`, `react` and `tailwind` - Drops in some example routes, schemas, collections and mutation code - Replaces the default `root.html.heex` layout to one suitable for a react-based SPA For this reason we recommend only running this on a fresh Phoenix project (with `Phoenix.Sync` installed). ## Example ```sh # install igniter.new mix archive.install hex igniter_new # create a new phoenix application and install phoenix_sync in `embedded` mode mix igniter.new my_app --install phoenix_sync --with phx.new --sync-mode embedded # setup my_app to use tanstack db #{example()} ``` ## Options * `--sync-pnpm` - Use `pnpm` as package manager if available (default) * `--no-sync-pnpm` - Use `npm` as package manager even if `pnpm` is installed """ end end if Code.ensure_loaded?(Igniter) do defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup do import Igniter.Project.Application, only: [app_name: 1] @shortdoc "#{__MODULE__.Docs.short_doc()}" @moduledoc __MODULE__.Docs.long_doc() use Igniter.Mix.Task @impl Igniter.Mix.Task def info(_argv, _composing_task) do %Igniter.Mix.Task.Info{ group: :phoenix_sync, adds_deps: [], installs: [], example: __MODULE__.Docs.example(), positional: [], composes: [], schema: [sync_pnpm: :boolean], defaults: [ sync_pnpm: true ], aliases: [], required: [] } end @impl Igniter.Mix.Task def igniter(igniter) do igniter |> configure_package_manager() |> install_assets() |> configure_watchers() |> add_task_aliases() |> write_layout() |> define_routes() |> add_caddy_file() |> remove_esbuild() |> add_ingest_flow() |> run_assets_setup() end defp add_ingest_flow(igniter) do alias Igniter.Libs.Phoenix web_module = Phoenix.web_module(igniter) {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter) igniter |> Phoenix.add_scope( "/ingest", """ pipe_through :api # example router for accepting optimistic writes from the client # See: https://tanstack.com/db/latest/docs/overview#making-optimistic-mutations # post "/mutations", Controllers.IngestController, :ingest """, arg2: web_module, router: router, placement: :after ) # phoenix doesn't generally namespace controllers under Web.Controllers # but igniter ignores my path here and puts the final file in the location # defined by the module name conventions |> Igniter.create_new_file( "lib/#{Macro.underscore(web_module)}/controllers/ingest_controller.ex", """ defmodule #{inspect(Module.concat([web_module, Controllers, IngestController]))} do use #{web_module}, :controller # See https://hexdocs.pm/phoenix_sync/readme.html#write-path-sync # alias Phoenix.Sync.Writer # def ingest(%{assigns: %{current_user: user}} = conn, %{"mutations" => mutations}) do # {:ok, txid, _changes} = # Writer.new() # |> Writer.allow( # Todos.Todo, # accept: [:insert], # check: &Ingest.check_event(&1, user) # ) # |> Writer.apply(mutations, Repo, format: Writer.Format.TanstackDB) # # json(conn, %{txid: txid}) # end end """ ) end defp add_caddy_file(igniter) do igniter |> create_or_replace_file("Caddyfile") end defp define_routes(igniter) do {igniter, router} = Igniter.Libs.Phoenix.select_router(igniter) igniter |> Igniter.Project.Module.find_and_update_module!( router, fn zipper -> with {:ok, zipper} <- Igniter.Code.Function.move_to_function_call( zipper, :get, 3, fn function_call -> Igniter.Code.Function.argument_equals?(function_call, 0, "/") && Igniter.Code.Function.argument_equals?(function_call, 1, PageController) && Igniter.Code.Function.argument_equals?(function_call, 2, :home) end ), {:ok, zipper} <- Igniter.Code.Function.update_nth_argument(zipper, 0, fn zipper -> {:ok, Igniter.Code.Common.replace_code( zipper, Sourceror.parse_string!(~s|"/*page"|) )} end), zipper <- Igniter.Code.Common.add_comment( zipper, "Forward all routes onto the root layout since tanstack router does our routing", [] ) do {:ok, zipper} end end ) end defp run_assets_setup(igniter) do if igniter.assigns[:test_mode?] do igniter else Igniter.add_task(igniter, "assets.setup") end end defp write_layout(igniter) do igniter |> create_or_replace_file( "lib/#{app_name(igniter)}_web/components/layouts/root.html.heex", "lib/web/components/layouts/root.html.heex" ) end defp remove_esbuild(igniter) do igniter |> Igniter.add_task("deps.unlock", ["tailwind", "esbuild"]) |> Igniter.add_task("deps.clean", ["tailwind", "esbuild"]) |> Igniter.Project.Deps.remove_dep(:esbuild) |> Igniter.Project.Deps.remove_dep(:tailwind) |> Igniter.Project.Config.remove_application_configuration("config.exs", :esbuild) |> Igniter.Project.Config.remove_application_configuration("config.exs", :tailwind) end defp add_task_aliases(igniter) do igniter |> set_alias( "assets.setup", "cmd --cd assets #{package_manager(igniter)} install --ignore-workspace" ) |> set_alias( "assets.build", [ "compile", "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode development" ] ) |> set_alias( "assets.deploy", [ "cmd --cd assets #{js_runner(igniter)} vite build --config vite.config.js --mode production", "phx.digest" ] ) end defp set_alias(igniter, task_name, command) do igniter |> Igniter.Project.TaskAliases.modify_existing_alias( task_name, fn zipper -> Igniter.Code.Common.replace_code(zipper, quote(do: [unquote(command)])) end ) end defp configure_watchers(igniter) do config = Sourceror.parse_string!(""" [ #{js_runner(igniter)}: [ "vite", "build", "--config", "vite.config.js", "--mode", "development", "--watch", cd: Path.expand("../assets", __DIR__) ] ] """) case Igniter.Libs.Phoenix.select_endpoint(igniter) do {igniter, nil} -> igniter {igniter, module} -> igniter |> Igniter.Project.Config.configure( "dev.exs", app_name(igniter), [module, :watchers], {:code, config} ) end end defp configure_package_manager(igniter) do if System.find_executable("pnpm") && Keyword.get(igniter.args.options, :sync_pnpm, true) do igniter |> Igniter.add_notice("Using pnpm as package manager") |> Igniter.assign(:package_manager, :pnpm) else if System.find_executable("npm") do igniter |> Igniter.add_notice("Using npm as package manager") |> Igniter.assign(:package_manager, :npm) else igniter |> Igniter.add_issue("Cannot find suitable package manager: please install pnpm or npm") end end end defp install_assets(igniter) do igniter |> Igniter.create_or_update_file( "assets/package.json", render_template(igniter, "assets/package.json"), fn src -> Rewrite.Source.update(src, :content, fn _content -> render_template(igniter, "assets/package.json") end) end ) |> create_new_file("assets/vite.config.ts") |> create_new_file("assets/tsconfig.node.json") |> create_new_file("assets/tsconfig.app.json") |> create_or_replace_file("assets/tsconfig.json") |> create_or_replace_file("assets/tailwind.config.js") |> create_new_file("assets/js/db/collections.ts") |> create_new_file("assets/js/db/schema.ts") |> create_new_file("assets/js/routes/__root.tsx") |> create_new_file("assets/js/routes/index.tsx") |> create_new_file("assets/js/routes/about.tsx") |> create_new_file("assets/js/components/todos.tsx") |> create_new_file("assets/js/api.ts") |> create_new_file("assets/js/app.tsx") |> create_or_replace_file("assets/css/app.css") |> Igniter.rm("assets/js/app.js") end defp create_new_file(igniter, path) do Igniter.create_new_file( igniter, path, render_template(igniter, path) ) end defp create_or_replace_file(igniter, path, template_path \\ nil) do contents = render_template(igniter, template_path || path) igniter |> Igniter.create_or_update_file( path, contents, &Rewrite.Source.update(&1, :content, fn _content -> contents end) ) end defp render_template(igniter, path) when is_binary(path) do template_contents(path, app_name: app_name(igniter) |> to_string()) end @doc false def template_contents(path, assigns) do template_dir() |> Path.join("#{path}.eex") |> Path.expand(__DIR__) |> EEx.eval_file(assigns: assigns) end @doc false def template_dir do :phoenix_sync |> :code.priv_dir() |> Path.join("igniter/phx.sync.tanstack_db") end defp js_runner(igniter) do case(igniter.assigns.package_manager) do :pnpm -> :pnpm :npm -> :npx end end defp package_manager(igniter) do igniter.assigns.package_manager end end else defmodule Mix.Tasks.Phx.Sync.TanstackDb.Setup do @shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use" @moduledoc __MODULE__.Docs.long_doc() use Mix.Task @impl Mix.Task def run(_argv) do Mix.shell().error(""" The task 'phx.sync.tanstack_db.setup' requires igniter. Please install igniter and try again. For more information, see: https://hexdocs.pm/igniter/readme.html#installation """) exit({:shutdown, 1}) end end end