Current section
Files
Jump to
Current section
Files
lib/mix/tasks/phx.sync.tanstack_db.setup_live.ex
# 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