# Adapted from https://github.com/electric-sql/phoenix_sync # Licensed under the Apache License 2.0 # # Modified defmodule Mix.Tasks.Phx.Sync.TanstackDb.SetupLive.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_live" 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` - 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 - Adds `spa_root.html.heex` layout, 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_fix --with phx.new --sync-mode embedded --no-sync-sandbox # 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.SetupLive do # import Igniter.Project.Application, only: [app_name: 1] # import Igniter.Libs.Phoenix, only: [web_module: 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_fix, adds_deps: [], installs: [], example: __MODULE__.Docs.example(), positional: [], composes: [], schema: [sync_pnpm: :boolean], defaults: [ sync_pnpm: true ], aliases: [], required: [] } end def app_name(igniter) do Igniter.Project.Application.app_name(igniter) end def web_module(igniter) do Igniter.Libs.Phoenix.web_module(igniter) end def web_dir(igniter) do "lib/#{app_name(igniter)}_web" end @impl Igniter.Mix.Task def igniter(igniter) do igniter # |> Igniter.compose_task("igniter.install", ["phoenix_vite"]) |> Igniter.compose_task( "igniter.install", ["ash_typescript", "--bundler", "vite", "--yes"] ) |> Igniter.compose_task( "igniter.install", ["phoenix_sync_fix@path:../phoenix_sync_fix", "--sync-mode", "embedded", "--no-sync-sandbox"] ) |> 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_or_replace_file("assets/package.json") # |> create_or_replace_file("assets/pnpm-lock.yaml") |> 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/index.tsx") |> create_new_file("assets/js/routeTree.gen.ts") |> create_or_replace_file("assets/css/app.css") |> create_or_replace_file("assets/js/app.js") |> create_new_file("compose.yaml") # |> create_or_replace_file("lib/web/components/layouts/spa_root.html.heex") # |> 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(), web_module: web_module(igniter) |> to_string(), web_dir: web_dir(igniter) ) 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_fix |> :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.SetupLive 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_live' 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