diff --git a/.github/workflows/elixir_tests.yml b/.github/workflows/elixir_tests.yml index 80899b2..fed7dc2 100644 --- a/.github/workflows/elixir_tests.yml +++ b/.github/workflows/elixir_tests.yml @@ -35,6 +35,10 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10 + - uses: actions/setup-node@v4 - name: "Set PG settings" run: | diff --git a/lib/mix/tasks/phx.sync.tanstack_db.setup.ex b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex new file mode 100644 index 0000000..edcbc93 --- /dev/null +++ b/lib/mix/tasks/phx.sync.tanstack_db.setup.ex @@ -0,0 +1,395 @@ +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 diff --git a/mix.exs b/mix.exs index 352a160..e6259ea 100644 --- a/mix.exs +++ b/mix.exs @@ -121,7 +121,7 @@ defmodule Phoenix.Sync.MixProject do "Source code" => "https://github.com/electric-sql/phoenix_sync" }, licenses: ["Apache-2.0"], - files: ~w(lib .formatter.exs mix.exs README.md LICENSE) + files: ~w(lib priv .formatter.exs mix.exs README.md LICENSE) ] end diff --git a/priv/igniter/phx.sync.tanstack_db/Caddyfile.eex b/priv/igniter/phx.sync.tanstack_db/Caddyfile.eex new file mode 100644 index 0000000..0185ff1 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/Caddyfile.eex @@ -0,0 +1,10 @@ +localhost:4001 { + reverse_proxy localhost:4000 + encode { + gzip + } + header { + Vary "Authorization" + } +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex b/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex new file mode 100644 index 0000000..93f4009 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/css/app.css.eex @@ -0,0 +1,15 @@ +@import "tailwindcss"; +@config "../tailwind.config.js"; + +@theme { + /* --color-brand: #fd4f00; */ +} + +@plugin "@tailwindcss/forms"; + +@custom-variant phx-click-loading (&:where(.phx-click-loading, .phx-click-loading *)); +@custom-variant phx-submit-loading (&:where(.phx-submit-loading, .phx-submit-loading *)); +@custom-variant phx-change-loading (&:where(.phx-change-loading, .phx-change-loading *)); +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/api.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/api.ts.eex new file mode 100644 index 0000000..a9a7690 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/api.ts.eex @@ -0,0 +1,67 @@ +import type { PendingMutation } from "@tanstack/react-db"; + +import { authCollection } from "./db/collections"; +import type { User } from "./db/schema"; + +type SignInResult = Pick; + +type IngestPayload = { + mutations: Omit[]; +}; + +const authHeaders = (): { authorization?: string } => { + const auth = authCollection.get("current"); + + return auth !== undefined ? { authorization: `Bearer ${auth.user_id}` } : {}; +}; + +const reqHeaders = () => { + return { + "content-type": "application/json", + accept: "application/json", + ...authHeaders(), + }; +}; + +export async function signIn( + username: string, + avatarUrl: string | undefined, +): Promise { + const data = { + avatar_url: avatarUrl !== undefined ? avatarUrl : null, + username, + }; + const headers = reqHeaders(); + + const response = await fetch("/auth/sign-in", { + method: "POST", + body: JSON.stringify(data), + headers, + }); + + if (response.ok) { + const { id: user_id }: SignInResult = await response.json(); + return user_id; + } +} + +export async function ingest( + payload: IngestPayload, +): Promise { + const headers = reqHeaders(); + + const response = await fetch("/ingest/mutations", { + method: "POST", + body: JSON.stringify(payload), + headers, + }); + + if (response.ok) { + const data = await response.json(); + const txid = data.txid as string | number; + const txidInt = typeof txid === "string" ? parseInt(txid, 10) : txid; + + return txidInt; + } +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/app.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/app.tsx.eex new file mode 100644 index 0000000..5cd7b08 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/app.tsx.eex @@ -0,0 +1,28 @@ +import "../css/app.css" +import { StrictMode } from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' + +// Import the generated route tree +import { routeTree } from './routeTree.gen' + +// Create a new router instance +const router = createRouter({ routeTree }) + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +// Render the app +const rootElement = document.getElementById('root')! +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render( + + + , + ) +} diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex new file mode 100644 index 0000000..073389a --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/components/todos.tsx.eex @@ -0,0 +1,39 @@ +import { useLiveQuery, eq } from "@tanstack/react-db"; +import { todoCollection } from "../db/collections"; + +export function Todos() { + const { data: todos } = useLiveQuery((query) => + query.from({ todo: todoCollection }), + ); + + const toggleTodo = (todo) => + todoCollection.update(todo.id, (draft) => { + draft.completed = !todo.completed; + }); + + return ( +
    + {todos.map((todo) => ( +
  • + toggleTodo(todo)} + id={`todo-${todo.id}`} + /> + +
  • + ))} +
+ ); +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex new file mode 100644 index 0000000..3dd11e5 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/db/collections.ts.eex @@ -0,0 +1,40 @@ +import {createCollection, localOnlyCollectionOptions,} from '@tanstack/react-db' +import { ingestMutations } from './mutations' + +import type { Value } from '@electric-sql/client' +import type { ElectricCollectionUtils } from '@tanstack/electric-db-collection' +import type { + InsertMutationFn, + UpdateMutationFn, + DeleteMutationFn, +} from '@tanstack/react-db' + +import { todoSchema } from './schema' +import type { Todo } from './schema' + + +// This is a local-only collection that is not synced to the server and +// immediately applies updates: +// +// https://tanstack.com/db/latest/docs/reference/functions/localonlycollectionoptions +// +// To sync your front-end with your database via Electric you should use +// an `electricCollection` as documented here: +// +// https://tanstack.com/db/latest/docs/collections/electric-collection +// +export const todoCollection = createCollection( + localOnlyCollectionOptions({ + getKey: (todo: Todo) => todo.id, + schema: todoSchema, + initialData: [ + { id: 1, title: 'Install Phoenix', completed: true }, + { id: 2, title: 'Run mix phx.sync.tanstack_db.setup', completed: true }, + { id: 3, title: 'Run the app', completed: true }, + { id: 4, title: 'Build a to-do app', completed: true }, + { id: 5, title: 'Convert to an Electric collection backed by Phoenix.Sync', completed: false }, + ], + }) +) + + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex new file mode 100644 index 0000000..2e17017 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/db/schema.ts.eex @@ -0,0 +1,9 @@ +import * as z from 'zod/v4' + +export const todoSchema = z.object({ + id: z.number(), + title: z.string(), + completed: z.boolean(), +}) + +export type Todo = z.infer diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex new file mode 100644 index 0000000..ce46c25 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/__root.tsx.eex @@ -0,0 +1,24 @@ +import { createRootRoute, Link, Outlet } from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +const RootLayout = () => ( + <> +
+ + Home + {' '} + + About + +
+
+ + + +) + + +export const Route = createRootRoute({ + component: RootLayout, +}) + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/about.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/about.tsx.eex new file mode 100644 index 0000000..cedc819 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/about.tsx.eex @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: About, +}) + +function About() { + return
Hello from About!
+} diff --git a/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex new file mode 100644 index 0000000..0d1e226 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/js/routes/index.tsx.eex @@ -0,0 +1,23 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { Todos } from "../components/todos"; + +export const Route = createFileRoute("/")({ + component: Index, +}); + +function Index() { + return ( +
+
+

+ Welcome to the Sync Stack +

+
+ +
+
+
+ ); +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex new file mode 100644 index 0000000..7750767 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/package.json.eex @@ -0,0 +1,52 @@ +{ + "name": "<%= @app_name %>", + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc -b && vite build --mode development", + "build:only": "vite build --mode development", + "build:prod": "vite build --mode production", + "dev": "vite", + "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,css,md}\"", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc -b" + }, + "dependencies": { + "phoenix": "file:../deps/phoenix", + "phoenix_html": "file:../deps/phoenix_html", + "phoenix_live_view": "file:../deps/phoenix_live_view", + "@electric-sql/client": "^1.0.10", + "@tanstack/electric-db-collection": "^0.1.18", + "@tanstack/react-db": "^0.1.16", + "@tanstack/react-router": "^1.131.35", + "@tanstack/react-router-devtools": "^1.131.35", + "react": "19.1.1", + "react-dom": "19.1.1", + "react-json-view-lite": "^2.4.2", + "zod": "^4.1.5" + }, + "devDependencies": { + "@eslint/compat": "^1.3.1", + "@eslint/js": "^9.32.0", + "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/vite": "^4.1.12", + "@tanstack/router-plugin": "^1.131.7", + "@types/node": "^24.2.1", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.7", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^9.32.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-react": "^7.37.5", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.12", + "typescript": "^5.9.2", + "vite": "^7.1.4" + } +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex b/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex new file mode 100644 index 0000000..ead377e --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/tailwind.config.js.eex @@ -0,0 +1,3 @@ +module.exports = { + content: ["./js/**/*.{js,jsx,ts,tsx}", "../lib/**/*.*ex"], +}; diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex new file mode 100644 index 0000000..fcea430 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.app.json.eex @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "*": ["../deps/*"] + } + }, + "include": ["js"] +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.json.eex new file mode 100644 index 0000000..e891e30 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.json.eex @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex new file mode 100644 index 0000000..b1fcf2f --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/tsconfig.node.json.eex @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "paths": { + "*": ["../deps/*"] + } + }, + "include": ["vite.config.ts"] +} + diff --git a/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex b/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex new file mode 100644 index 0000000..2b813f5 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/assets/vite.config.ts.eex @@ -0,0 +1,46 @@ +import { defineConfig, loadEnv } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +export default defineConfig(({ command, mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const isProd = mode === 'production' + + return { + publicDir: false, + build: { + outDir: '../priv/static', + target: ['es2022'], + minify: isProd, + sourcemap: !isProd, + rollupOptions: { + input: './js/app.tsx', + output: { + assetFileNames: 'assets/[name][extname]', + chunkFileNames: 'assets/chunk/[name].js', + entryFileNames: 'assets/[name].js', + }, + }, + }, + define: { + __APP_ENV__: env.APP_ENV, + // Explicitly force production React + 'process.env.NODE_ENV': JSON.stringify(isProd ? 'production' : 'development'), + 'import.meta.env.PROD': isProd, + 'import.meta.env.DEV': !isProd, + }, + plugins: [ + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + routesDirectory: "./js/routes", + generatedRouteTree: "./js/routeTree.gen.ts", + }), + react(), + tailwindcss(), + ], + } +}) + diff --git a/priv/igniter/phx.sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex b/priv/igniter/phx.sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex new file mode 100644 index 0000000..2971279 --- /dev/null +++ b/priv/igniter/phx.sync.tanstack_db/lib/web/components/layouts/root.html.heex.eex @@ -0,0 +1,17 @@ + + + + + + + <.live_title default="<%= Macro.camelize(@app_name) %>" suffix=" ยท Phoenix Framework"> + {assigns[:page_title]} + + + + + +
+ + diff --git a/test/mix/tasks/phx.sync.tanstack_db.setup_test.exs b/test/mix/tasks/phx.sync.tanstack_db.setup_test.exs new file mode 100644 index 0000000..959f0d7 --- /dev/null +++ b/test/mix/tasks/phx.sync.tanstack_db.setup_test.exs @@ -0,0 +1,127 @@ +defmodule Mix.Tasks.Phx.Sync.TanstackDb.SetupTest do + use ExUnit.Case, async: true + + import Igniter.Test + + import Mix.Tasks.Phx.Sync.TanstackDb.Setup, only: [template_dir: 0, template_contents: 2] + + defp assert_renders_template(igniter, {template_path, render_path}) do + igniter + |> assert_content_equals(render_path, template_contents(template_path, app_name: "test")) + end + + test "package.json" do + json = template_contents("assets/package.json", app_name: "test") + assert json =~ ~r("type": "module") + assert json =~ ~r("name": "test") + assert json =~ ~r("version": "0.0.0") + end + + test "installs assets from templates" do + templates = + template_dir() + |> Path.join("**/*.*") + |> Path.wildcard() + |> Enum.map(&Path.relative_to(&1, template_dir())) + |> Enum.map(fn path -> + dir = + Path.dirname(path) + |> String.replace(~r/^\.$/, "") + + render_dir = + dir |> String.replace(~r|lib/web/components|, "lib/test_web/components") + + file = Path.basename(path) + + file = String.replace(file, ~r/\.eex$/, "") + + {Path.join(dir, file), Path.join(render_dir, file)} + end) + + igniter = + phx_test_project() + |> Igniter.compose_task("phx.sync.tanstack_db.setup", []) + + assert [] == igniter.warnings + + for template <- templates do + assert_renders_template(igniter, template) + end + end + + test "patches tasks" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) + + assert_has_patch(igniter, "mix.exs", """ + - | {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + - | {:tailwind, "~> 0.3", runtime: Mix.env() == :dev}, + """) + + assert_has_patch(igniter, "mix.exs", """ + - | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + - | "assets.build": ["compile", "tailwind test", "esbuild test"], + + | "assets.setup": ["cmd --cd assets pnpm install --ignore-workspace"], + + | "assets.build": [ + + | "compile", + + | "cmd --cd assets pnpm vite build --config vite.config.js --mode development" + + | ], + | "assets.deploy": [ + - | "tailwind test --minify", + - | "esbuild test --minify", + + | "cmd --cd assets pnpm vite build --config vite.config.js --mode production", + + """) + end + + test "configures watchers in dev" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) + + assert_has_patch(igniter, "config/dev.exs", """ + - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, + - | tailwind: {Tailwind, :install_and_run, [:test, ~w(--watch)]} + + | pnpm: [ + + | "vite", + + | "build", + + | "--config", + + | "vite.config.js", + + | "--mode", + + | "development", + + | "--watch", + + | cd: Path.expand("../assets", __DIR__) + + | ] + """) + end + + test "uses npm if told" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--no-sync-pnpm"]) + + assert_has_patch(igniter, "config/dev.exs", """ + - | esbuild: {Esbuild, :install_and_run, [:test, ~w(--sourcemap=inline --watch)]}, + - | tailwind: {Tailwind, :install_and_run, [:test, ~w(--watch)]} + + | npx: [ + + | "vite", + + | "build", + + | "--config", + + | "vite.config.js", + + | "--mode", + + | "development", + + | "--watch", + + | cd: Path.expand("../assets", __DIR__) + + | ] + """) + end + + test "removes app.js" do + igniter = + phx_test_project() + |> Igniter.compose_task("phx.sync.tanstack_db.setup", ["--sync-pnpm"]) + + assert_rms(igniter, ["assets/js/app.js"]) + end +end