diff --git a/lib/mix/tasks/ash_typescript.install.ex b/lib/mix/tasks/ash_typescript.install.ex index dd5ecf83..8fd043cb 100644 --- a/lib/mix/tasks/ash_typescript.install.ex +++ b/lib/mix/tasks/ash_typescript.install.ex @@ -8,6 +8,62 @@ if Code.ensure_loaded?(Igniter) do @moduledoc """ #{@shortdoc} + + ## Options + + * `--framework` - The frontend framework to use. Supported values: + * `react` - React 19 (default) + * `react18` - React 18 + * `vue` - Vue.js 3 + * `svelte` - Svelte 5 + * `solid` - SolidJS + + * `--bundler` - The bundler to use. Supported values: + * `esbuild` - Phoenix's built-in esbuild (default) + * `vite` - Vite for faster builds and HMR + + * `--bun` - Use Bun instead of npm for package management and script execution. + Improves build performance when using the esbuild bundler. + + * `--inertia` - Install with Inertia.js support for SSR. + Creates dedicated entry points (`js/index.ts` and `js/ssr.ts`) and Inertia-specific + pipeline. Requires `--framework` to be specified. + Inertia installs currently require `--bundler esbuild`. + + ## Examples + + # Install with React and esbuild (default) + mix igniter.install ash_typescript --framework react + + # Install with Vue and Vite + mix igniter.install ash_typescript --framework vue --bundler vite + + # Install with React using Bun + mix igniter.install ash_typescript --framework react --bun + + # Install with Svelte and Inertia.js + mix igniter.install ash_typescript --framework svelte --inertia + + ## Generated Files + + The installer creates the following structure: + + * RPC controller at `lib/my_app_web/controllers/ash_typescript_rpc_controller.ex` + * RPC routes in your router + * AshTypescript configuration in `config/config.exs` + * Framework-specific entry points and components in `assets/js/` + * Layouts in `lib/my_app_web/components/layouts/` + + ## Configuration + + The installer adds the following configuration to `config/config.exs`: + + config :ash_typescript, + output_file: "assets/js/ash_rpc.ts", + run_endpoint: "/rpc/run", + validate_endpoint: "/rpc/validate", + input_field_formatter: :camel_case, + output_field_formatter: :camel_case """ use Igniter.Mix.Task @@ -17,8 +73,9 @@ if Code.ensure_loaded?(Igniter) do %Igniter.Mix.Task.Info{ group: :ash, installs: [], - schema: [framework: :string], - defaults: [framework: nil] + schema: [framework: :string, bundler: :string, bun: :boolean, inertia: :boolean], + defaults: [framework: nil, bundler: "esbuild", bun: false, inertia: false], + composes: [] } end @@ -27,11 +84,70 @@ if Code.ensure_loaded?(Igniter) do app_name = Igniter.Project.Application.app_name(igniter) web_module = Igniter.Libs.Phoenix.web_module(igniter) framework = Keyword.get(igniter.args.options, :framework, nil) + bundler = Keyword.get(igniter.args.options, :bundler, "esbuild") + use_bun = Keyword.get(igniter.args.options, :bun, false) + use_inertia = Keyword.get(igniter.args.options, :inertia, false) # Validate framework parameter igniter = validate_framework(igniter, framework) + # Validate bundler + igniter = validate_bundler(igniter, bundler) + + # Inertia requires a framework + igniter = + if use_inertia and is_nil(framework) do + Igniter.add_issue( + igniter, + "The --inertia flag requires a --framework to be specified (react, react18, vue, or svelte)" + ) + else + igniter + end + + igniter = + if use_inertia and framework == "solid" do + Igniter.add_issue( + igniter, + "Solid is not currently supported with --inertia. Use --framework react, react18, vue, or svelte for Inertia, or use Solid without --inertia." + ) + else + igniter + end - react_enabled = framework == "react" + igniter = + if use_inertia and bundler == "vite" do + Igniter.add_issue( + igniter, + "Inertia is currently only supported with --bundler esbuild. Use --bundler esbuild for Inertia, or remove --inertia to use Vite." + ) + else + igniter + end + + # Store of args for use after fresh igniter + args = igniter.args + + igniter = + if bundler == "vite" do + install_args = + if use_bun, do: ["--yes", "--bun"], else: ["--yes"] + + # Install phoenix_vite and return the resulting igniter + # The install function modifies the project, so we need to start fresh + # but preserve our args + Igniter.Util.Install.install( + [{:phoenix_vite, "~> 0.4.2"}], + install_args, + igniter + ) + + # After phoenix_vite install completes, we get a fresh igniter + # but need to preserve our original args + Igniter.new() + |> Map.put(:args, args) + else + igniter + end igniter = igniter @@ -40,25 +156,126 @@ if Code.ensure_loaded?(Igniter) do |> add_ash_typescript_config() |> create_rpc_controller(app_name, web_module) |> add_rpc_routes(web_module) + |> maybe_fix_vite_runtime_manifest_cache(bundler, app_name) igniter = - if react_enabled do - igniter - |> create_package_json() - |> create_react_index() - |> update_tsconfig() - |> update_esbuild_config(app_name) - |> create_or_update_page_controller(web_module) - |> create_index_template(web_module) - |> add_page_index_route(web_module) - else - igniter + case {framework, use_inertia, bundler} do + {nil, _, _} -> + igniter + + {"solid", true, _} -> + # Solid + Inertia is explicitly unsupported. We add an issue above and + # intentionally skip the inertia setup pipeline to avoid runtime crashes. + igniter + + {_, true, "vite"} -> + # Inertia + Vite is currently unsupported. We add an issue above and + # intentionally skip the inertia setup pipeline to avoid runtime crashes. + igniter + + {framework, true, _bundler} -> + # Inertia flow: separate entry points, layout, controller, and routes + igniter + |> create_package_json(bundler, framework) + |> add_prism_deps() + |> add_prism_stylesheet() + |> add_inertia_deps(framework) + |> update_tsconfig(framework) + |> setup_framework_bundler_for_inertia(app_name, bundler, use_bun, framework) + |> add_inertia_dep() + |> add_inertia_config(web_module) + |> setup_inertia_web_module(web_module) + |> add_inertia_plug_to_router() + |> create_inertia_root_layout(web_module, bundler, framework) + |> create_inertia_entry_point(framework, bundler) + |> create_inertia_ssr_entry_point(framework) + |> setup_inertia_ssr_support(app_name, web_module, bundler, framework) + |> create_inertia_page_component(framework) + |> create_inertia_page_controller(web_module, framework) + |> add_inertia_pipeline_and_routes(web_module) + + {framework, false, _bundler} -> + # SPA flow: client-side routing with spa_root layout + igniter + |> create_package_json(bundler, framework) + |> add_prism_deps() + |> add_prism_stylesheet() + |> create_index_page(framework) + |> update_tsconfig(framework) + |> setup_framework_bundler(app_name, bundler, use_bun, framework) + |> create_spa_root_layout(web_module, bundler, framework) + |> create_or_update_page_controller(web_module, bundler) + |> create_index_template(web_module, bundler, framework) + |> add_page_index_route(web_module) end igniter - |> add_next_steps_notice(framework) + |> add_next_steps_notice(framework, bundler, use_inertia) + end + + defp setup_framework_bundler(igniter, app_name, "esbuild", use_bun, framework) + when framework in ["vue", "svelte", "solid"] do + # Vue, Svelte, and Solid need custom build scripts with esbuild plugins + igniter + |> create_esbuild_script(framework) + |> update_esbuild_config_with_script(app_name, use_bun, framework) + |> update_root_layout_for_esbuild() + end + + defp setup_framework_bundler(igniter, app_name, "esbuild", use_bun, framework) do + igniter + |> update_esbuild_config(app_name, use_bun, framework) + |> update_root_layout_for_esbuild() + end + + defp setup_framework_bundler(igniter, _app_name, "vite", _use_bun, framework) + when framework in ["vue", "svelte", "solid"] do + # Add vite plugins for Vue/Svelte/Solid + igniter + |> update_vite_config_with_framework(framework) + end + + defp setup_framework_bundler(igniter, _app_name, "vite", _use_bun, framework) + when framework in ["react", "react18"] do + # Add React entry point to vite config + igniter + |> update_vite_config_with_framework("react") + end + + defp setup_framework_bundler(igniter, _app_name, "vite", _use_bun, _framework), do: igniter + + # PhoenixVite installs cache_static_manifest_latest in runtime.exs. + # During MIX_ENV=prod assets.deploy, that manifest may not exist yet. + defp maybe_fix_vite_runtime_manifest_cache(igniter, "vite", app_name) do + runtime_path = "config/runtime.exs" + + direct_call = + "cache_static_manifest_latest: PhoenixVite.cache_static_manifest_latest(:#{app_name})" + + tuple_call = + ~s|cache_static_manifest_latest: PhoenixVite.cache_static_manifest_latest({:#{app_name}, "priv/static/.vite/manifest.json"})| + + guarded_call = + ~s|cache_static_manifest_latest: if(File.exists?(Application.app_dir(:#{app_name}, "priv/static/.vite/manifest.json")), do: PhoenixVite.cache_static_manifest_latest(:#{app_name}), else: %{})| + + Igniter.update_file(igniter, runtime_path, fn source -> + content = source.content + + updated_content = + content + |> String.replace(direct_call, guarded_call) + |> String.replace(tuple_call, guarded_call) + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) end + defp maybe_fix_vite_runtime_manifest_cache(igniter, _bundler, _app_name), do: igniter + defp validate_framework(igniter, framework) do case framework do nil -> @@ -67,10 +284,41 @@ if Code.ensure_loaded?(Igniter) do "react" -> igniter + "react18" -> + igniter + + "vue" -> + igniter + + "svelte" -> + igniter + + "solid" -> + igniter + invalid_framework -> Igniter.add_issue( igniter, - "Invalid framework '#{invalid_framework}'. Currently supported frameworks: react" + "Invalid framework '#{invalid_framework}'. Currently supported frameworks: react, react18, vue, svelte, solid" + ) + end + end + + defp validate_bundler(igniter, bundler) do + case bundler do + nil -> + igniter + + "vite" -> + igniter + + "esbuild" -> + igniter + + invalid_bundler -> + Igniter.add_issue( + igniter, + "Invalid bundler #{invalid_bundler}. Currently supported bundlers: vite, esbuild" ) end end @@ -233,31 +481,275 @@ if Code.ensure_loaded?(Igniter) do ) end - defp create_package_json(igniter) do - package_json_content = """ - { - "devDependencies": { - "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.9" + defp create_package_json(igniter, "vite", framework) do + igniter + |> Igniter.create_or_update_file("assets/package.json", "{}\n", fn source -> + source + end) + |> update_package_json_with_framework(framework, "vite") + end + + defp create_package_json(igniter, bundler, framework) do + # For esbuild, ensure package.json exists with Phoenix deps, + # then merge in framework-specific deps + base_package_json = + %{ + "dependencies" => %{ + "phoenix" => "file:../deps/phoenix", + "phoenix_html" => "file:../deps/phoenix_html", + "phoenix_live_view" => "file:../deps/phoenix_live_view", + "topbar" => "^3.0.0" + }, + "devDependencies" => %{ + "daisyui" => "^5.0.0" + } + } + |> encode_pretty_json() + + igniter + |> Igniter.create_or_update_file("assets/package.json", base_package_json, fn source -> + source + end) + |> update_package_json_with_framework(framework, bundler) + |> update_vendor_imports() + end + + defp get_framework_deps("vue", "vite") do + %{ + dependencies: %{ + "@tanstack/vue-query" => "^5.89.0", + "@tanstack/vue-table" => "^8.21.3", + "@tanstack/vue-virtual" => "^3.13.12", + "vue" => "^3.5.16" }, - "dependencies": { - "@tanstack/react-query": "^5.89.0", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "react": "^19.1.1", - "react-dom": "^19.1.1" + dev_dependencies: %{ + "@vitejs/plugin-vue" => "^5.2.4" } } - """ + end + + defp get_framework_deps("vue", _bundler) do + %{ + dependencies: %{ + "@tanstack/vue-query" => "^5.89.0", + "@tanstack/vue-table" => "^8.21.3", + "@tanstack/vue-virtual" => "^3.13.12", + "vue" => "^3.5.16" + }, + dev_dependencies: %{ + "esbuild-plugin-vue3" => "^0.4.2" + } + } + end + + defp get_framework_deps("svelte", "vite") do + %{ + dependencies: %{ + "@tanstack/svelte-query" => "^5.89.0", + "svelte" => "^5.33.0" + }, + dev_dependencies: %{ + "@sveltejs/vite-plugin-svelte" => "^5.0.3" + } + } + end + + defp get_framework_deps("svelte", _bundler) do + %{ + dependencies: %{ + "@tanstack/svelte-query" => "^5.89.0", + "svelte" => "^5.33.0" + }, + dev_dependencies: %{ + "esbuild-svelte" => "^0.9.3" + } + } + end + + defp get_framework_deps("solid", "vite") do + %{ + dependencies: %{ + "@tanstack/solid-query" => "^5.89.0", + "solid-js" => "^1.9.9" + }, + dev_dependencies: %{ + "vite-plugin-solid" => "^2.11.9" + } + } + end + + defp get_framework_deps("solid", _bundler) do + %{ + dependencies: %{ + "@tanstack/solid-query" => "^5.89.0", + "solid-js" => "^1.9.9" + }, + dev_dependencies: %{ + "esbuild-plugin-solid" => "^0.6.0" + } + } + end + + defp get_framework_deps("react", "vite") do + %{ + dependencies: %{ + "@tanstack/react-query" => "^5.89.0", + "@tanstack/react-table" => "^8.21.3", + "@tanstack/react-virtual" => "^3.13.12", + "react" => "^19.1.1", + "react-dom" => "^19.1.1" + }, + dev_dependencies: %{ + "@types/react" => "^19.1.13", + "@types/react-dom" => "^19.1.9", + "@vitejs/plugin-react" => "^4.5.0" + } + } + end + + defp get_framework_deps("react18", "vite") do + %{ + dependencies: %{ + "@tanstack/react-query" => "^5.89.0", + "@tanstack/react-table" => "^8.21.3", + "@tanstack/react-virtual" => "^3.13.12", + "react" => "^18.3.1", + "react-dom" => "^18.3.1" + }, + dev_dependencies: %{ + "@types/react" => "^18.3.23", + "@types/react-dom" => "^18.3.7", + "@vitejs/plugin-react" => "^4.5.0" + } + } + end + + defp get_framework_deps("react", _bundler) do + %{ + dependencies: %{ + "@tanstack/react-query" => "^5.89.0", + "@tanstack/react-table" => "^8.21.3", + "@tanstack/react-virtual" => "^3.13.12", + "react" => "^19.1.1", + "react-dom" => "^19.1.1" + }, + dev_dependencies: %{ + "@types/react" => "^19.1.13", + "@types/react-dom" => "^19.1.9" + } + } + end + + defp get_framework_deps("react18", _bundler) do + %{ + dependencies: %{ + "@tanstack/react-query" => "^5.89.0", + "@tanstack/react-table" => "^8.21.3", + "@tanstack/react-virtual" => "^3.13.12", + "react" => "^18.3.1", + "react-dom" => "^18.3.1" + }, + dev_dependencies: %{ + "@types/react" => "^18.3.23", + "@types/react-dom" => "^18.3.7" + } + } + end + + defp update_vendor_imports(igniter) do + # Update app.js to use npm imports instead of vendor paths + igniter + |> Igniter.update_file("assets/js/app.js", fn source -> + Rewrite.Source.update(source, :content, fn content -> + String.replace(content, "../vendor/topbar", "topbar") + end) + end) + |> Igniter.update_file("assets/css/app.css", fn source -> + Rewrite.Source.update(source, :content, fn content -> + content + |> String.replace("../vendor/daisyui-theme", "daisyui/theme") + |> String.replace("../vendor/daisyui", "daisyui") + end) + end) + |> delete_vendor_files() + end + defp delete_vendor_files(igniter) do + # Delete vendor files except heroicons igniter - |> Igniter.create_new_file("assets/package.json", package_json_content, on_exists: :warning) + |> Igniter.rm("assets/vendor/topbar.js") + |> Igniter.rm("assets/vendor/daisyui.js") + |> Igniter.rm("assets/vendor/daisyui-theme.js") + end + + defp update_package_json_with_framework(igniter, framework, bundler) do + deps = get_framework_deps(framework, bundler) + + update_package_json(igniter, fn package_json -> + package_json + |> merge_package_section("dependencies", deps.dependencies) + |> merge_package_section("devDependencies", deps.dev_dependencies) + end) + end + + defp add_prism_deps(igniter) do + update_package_json(igniter, fn package_json -> + merge_package_section(package_json, "dependencies", %{"prismjs" => "^1.30.0"}) + end) + end + + defp add_prism_stylesheet(igniter) do + Igniter.update_file(igniter, "assets/css/app.css", fn source -> + content = source.content + prism_import = ~s|@import "prismjs/themes/prism-tomorrow.css";| + + if String.contains?(content, prism_import) do + source + else + Rewrite.Source.update(source, :content, prism_import <> "\n" <> content) + end + end) + end + + defp update_package_json(igniter, updater) do + Igniter.update_file(igniter, "assets/package.json", fn source -> + case Jason.decode(source.content) do + {:ok, package_json} -> + updated_package_json = updater.(package_json) + + if updated_package_json == package_json do + source + else + Rewrite.Source.update(source, :content, encode_pretty_json(updated_package_json)) + end + + {:error, _error} -> + source + end + end) + end + + defp merge_package_section(package_json, section, deps) when is_map(deps) do + current_deps = Map.get(package_json, section, %{}) + Map.put(package_json, section, Map.merge(current_deps, deps)) end - defp create_react_index(igniter) do + defp encode_pretty_json(data) do + Jason.encode!(data, pretty: true) <> "\n" + end + + defp create_index_page(igniter, "react18"), do: create_index_page(igniter, "react") + + defp create_index_page(igniter, "react") do + page_body = get_react_page_body() + react_index_content = """ - import React, { useEffect } from "react"; + import React from "react"; import { createRoot } from "react-dom/client"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; // Declare Prism for TypeScript declare global { @@ -266,8 +758,10 @@ if Code.ensure_loaded?(Igniter) do } } - const AshTypescriptGuide = () => { - useEffect(() => { + (window as any).Prism = Prism; + + export default function App() { + React.useEffect(() => { // Trigger Prism highlighting after component mounts if (window.Prism) { window.Prism.highlightAll(); @@ -275,233 +769,141 @@ if Code.ensure_loaded?(Igniter) do }, []); return ( -
-
-
- AshTypescript Logo -
-

- AshTypescript -

-

- Type-safe TypeScript bindings for Ash Framework -

-
-
+ #{page_body} + ); + } -
-
-
-
- 1 -
-

- Configure RPC in Your Domain -

-
-

- Add the AshTypescript.Rpc extension to your domain and configure RPC actions: -

-
-                    
-      {\`defmodule MyApp.Accounts do
-        use Ash.Domain, extensions: [AshTypescript.Rpc]
+      const root = createRoot(document.getElementById("app")!);
 
-        typescript_rpc do
-          resource MyApp.Accounts.User do
-            rpc_action :get_by_email, :get_by_email
-            rpc_action :list_users, :read
-            rpc_action :get_user, :read
-          end
-        end
+      root.render(
+        
+          
+        ,
+      );
+      """
 
-        resources do
-          resource MyApp.Accounts.User
-        end
-      end\`}
-                    
-                  
-
+ igniter + |> Igniter.create_new_file("assets/js/index.tsx", react_index_content, on_exists: :warning) + end -
-
-
- 2 -
-

- TypeScript Auto-Generation -

-
-

- When running the dev server, TypeScript types are automatically generated for you: -

-
-                    mix phx.server
-                  
-
-

- ✨ Automatic regeneration: TypeScript files are automatically regenerated whenever you make changes to your resources or expose new RPC actions. No manual codegen step required during development! -

-
-

- For production builds or manual generation, you can also run: -

-
-                    mix ash_typescript.codegen --output "assets/js/ash_generated.ts"
-                  
-
+ defp create_index_page(igniter, "vue") do + {script_content, template_content} = get_vue_page_content() -
-
-
- 3 -
-

- Import and Use Generated Functions -

-
-

- Import the generated RPC functions in your TypeScript/React code: -

-
-                    
-      {\`import { getByEmail, listUsers, getUser } from "./ash_generated";
+      vue_component = script_content <> "\n" <> template_content
 
-      // Use the typed RPC functions
-      const findUserByEmail = async (email: string) => {
-        try {
-          const result = await getByEmail({ email });
-          if (result.success) {
-            console.log("User found:", result.data);
-            return result.data;
-          } else {
-            console.error("User not found:", result.errors);
-            return null;
-          }
-        } catch (error) {
-          console.error("Network error:", error);
-          return null;
-        }
-      };
+      vue_index_content = """
+      import { createApp } from "vue";
+      import App from "./App.vue";
+      import Prism from "prismjs";
+      import "prismjs/components/prism-elixir";
+      import "prismjs/components/prism-bash";
+      import "prismjs/components/prism-typescript";
 
-      const fetchUsers = async () => {
-        try {
-          const result = await listUsers();
-          if (result.success) {
-            console.log("Users:", result.data);
-          } else {
-            console.error("Failed to fetch users:", result.errors);
-          }
-        } catch (error) {
-          console.error("Network error:", error);
-        }
-      };\`}
-                    
-                  
-
+ (window as any).Prism = Prism; -
-

- Learn More & Examples -

-
- -
- 📚 -
-
-

Documentation

-

Complete API reference and guides on HexDocs

-
-
+ const app = createApp(App); + app.mount("#app"); + """ - -
- 🔧 -
-
-

Source Code

-

View the source, report issues, and contribute on GitHub

-
-
+ igniter + |> Igniter.create_new_file("assets/js/App.vue", vue_component, on_exists: :warning) + |> Igniter.create_new_file("assets/js/index.ts", vue_index_content, on_exists: :warning) + end - -
- 🚀 -
-
-

Demo App

-

See AshTypescript with TanStack Query & Table in action

-

by ChristianAlexander

-
-
-
-
+ defp create_index_page(igniter, "svelte") do + {script_content, template_content} = get_svelte_page_content() -
-
-
- 🚀 -
-
-

- Ready to Get Started? -

-

- Check your generated RPC functions and start building type-safe interactions between your frontend and Ash resources! -

-
-
-
-
+ svelte_component = script_content <> "\n" <> template_content + + svelte_index_content = """ + import App from "./App.svelte"; + import { mount } from "svelte"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; + + (window as any).Prism = Prism; + + const app = mount(App, { + target: document.getElementById("app")!, + }); + + export default app; + """ + + igniter + |> Igniter.create_new_file("assets/js/App.svelte", svelte_component, on_exists: :warning) + |> Igniter.create_new_file("assets/js/index.ts", svelte_index_content, on_exists: :warning) + end + + defp create_index_page(igniter, "solid") do + page_body = get_solid_page_body() + + solid_index_content = """ + import { onMount } from "solid-js"; + import { render } from "solid-js/web"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; + + declare global { + interface Window { + Prism: any; + } + } + + (window as any).Prism = Prism; + + const App = () => { + onMount(() => { + if (window.Prism) { + window.Prism.highlightAll(); + } + }); + + return ( + #{page_body} ); }; - const root = createRoot(document.getElementById("app")!); - - root.render( - - - , - ); + render(() => , document.getElementById("app")!); """ igniter - |> Igniter.create_new_file("assets/js/index.tsx", react_index_content, on_exists: :warning) + |> Igniter.create_new_file("assets/js/index.tsx", solid_index_content, on_exists: :warning) end - defp update_tsconfig(igniter) do + defp update_tsconfig(igniter, framework) do igniter |> Igniter.update_file("assets/tsconfig.json", fn source -> content = source.content - needs_jsx = not String.contains?(content, ~s("jsx":)) + jsx_setting = + case framework do + f when f in ["react", "react18"] -> "react-jsx" + "solid" -> "preserve" + _ -> nil + end + + needs_jsx = + if is_nil(jsx_setting) do + false + else + not String.contains?(content, ~s("jsx":)) + end + + needs_jsx_import_source = + framework == "solid" and not String.contains?(content, ~s("jsxImportSource":)) + needs_interop = not String.contains?(content, ~s("esModuleInterop":)) - if needs_jsx or needs_interop do + if needs_jsx or needs_jsx_import_source or needs_interop do updated_content = content updated_content = - if needs_jsx or needs_interop do + if needs_jsx or needs_jsx_import_source or needs_interop do case Regex.run(~r/"compilerOptions":\s*\{/, updated_content, return: :index) do [{start, length}] -> insertion_point = start + length @@ -512,7 +914,12 @@ if Code.ensure_loaded?(Igniter) do options_to_add = if needs_jsx, - do: [~s(\n "jsx": "react-jsx",) | options_to_add], + do: [~s(\n "jsx": "#{jsx_setting}",) | options_to_add], + else: options_to_add + + options_to_add = + if needs_jsx_import_source, + do: [~s(\n "jsxImportSource": "solid-js",) | options_to_add], else: options_to_add options_to_add = @@ -536,8 +943,16 @@ if Code.ensure_loaded?(Igniter) do end) end - defp update_esbuild_config(igniter, app_name) do + defp update_esbuild_config(igniter, app_name, use_bun, framework) do + npm_install_task = + if use_bun, do: "ash_typescript.npm_install --bun", else: "ash_typescript.npm_install" + + entry_file = get_entry_file(framework) + igniter + |> Igniter.Project.TaskAliases.add_alias("assets.setup", npm_install_task, + if_exists: :append + ) |> Igniter.update_elixir_file("config/config.exs", fn zipper -> is_esbuild_node = fn {:config, _, [{:__block__, _, [:esbuild]} | _rest]} -> true @@ -580,142 +995,2620 @@ if Code.ensure_loaded?(Igniter) do case args_node do {{:__block__, block_meta, [:args]}, {:sigil_w, sigil_meta, [{:<<>>, string_meta, [args_string]}, sigil_opts]}} -> - if String.contains?(args_string, "js/index.tsx") do - zipper - else - new_args_string = "js/index.tsx " <> args_string + # Add entry file and change output dir from /assets/js to /assets + new_args_string = + if String.contains?(args_string, entry_file) do + args_string + else + entry_file <> " " <> args_string + end - new_args_node = - {{:__block__, block_meta, [:args]}, - {:sigil_w, sigil_meta, [{:<<>>, string_meta, [new_args_string]}, sigil_opts]}} + # Change output directory from assets/js to assets (flat output like build.js) + new_args_string = + String.replace( + new_args_string, + "--outdir=../priv/static/assets/js", + "--outdir=../priv/static/assets" + ) + + # Add code splitting flags for module support (CSS imported via JS) + new_args_string = + if String.contains?(new_args_string, "--splitting") do + new_args_string + else + new_args_string <> " --splitting" + end - Sourceror.Zipper.replace(zipper, new_args_node) - end + new_args_string = + if String.contains?(new_args_string, "--format=esm") do + new_args_string + else + new_args_string <> " --format=esm" + end + + new_args_node = + {{:__block__, block_meta, [:args]}, + {:sigil_w, sigil_meta, [{:<<>>, string_meta, [new_args_string]}, sigil_opts]}} + + Sourceror.Zipper.replace(zipper, new_args_node) _ -> zipper end end) + |> normalize_esbuild_node_path_env() end - defp create_or_update_page_controller(igniter, web_module) do - clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") - - controller_path = - clean_web_module - |> String.replace_suffix("Web", "") - |> Macro.underscore() + # Ensure esbuild receives NODE_PATH as a path-delimited string. + # Some templates can emit a list value, which breaks module resolution in prod. + defp normalize_esbuild_node_path_env(igniter) do + joined_node_path = + ~s|env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")}| - page_controller_path = "lib/#{controller_path}_web/controllers/page_controller.ex" + Igniter.update_file(igniter, "config/config.exs", fn source -> + content = source.content - page_controller_content = """ - defmodule #{clean_web_module}.PageController do - use #{clean_web_module}, :controller + updated_content = + content + |> String.replace( + ~s|env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}|, + joined_node_path + ) + |> String.replace( + ~s|env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}|, + joined_node_path + ) + |> String.replace( + ~s|env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Mix.Project.build_path()], ":")}|, + joined_node_path + ) - def index(conn, _params) do - render(conn, :index) + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) end - end - """ - - case Igniter.exists?(igniter, page_controller_path) do - false -> - igniter - |> Igniter.create_new_file(page_controller_path, page_controller_content) + end) + end - true -> - igniter - |> Igniter.update_elixir_file(page_controller_path, fn zipper -> - case Igniter.Code.Common.move_to(zipper, &function_named?(&1, :index, 2)) do - {:ok, _zipper} -> - zipper + defp get_entry_file("react"), do: "js/index.tsx" + defp get_entry_file("react18"), do: "js/index.tsx" + defp get_entry_file("solid"), do: "js/index.tsx" + defp get_entry_file("vue"), do: "js/index.ts" + defp get_entry_file("svelte"), do: "js/index.ts" + defp get_entry_file(_), do: "js/index.ts" + + defp create_esbuild_script(igniter, "vue") do + build_script = """ + const esbuild = require("esbuild"); + const vuePlugin = require("esbuild-plugin-vue3"); + const path = require("path"); + + const args = process.argv.slice(2); + const watch = args.includes("--watch"); + const deploy = args.includes("--deploy"); + const mixBuildPath = + process.env.MIX_BUILD_PATH || + path.resolve(__dirname, "..", "_build", process.env.MIX_ENV || "dev"); + const fallbackDevBuildPath = path.resolve(__dirname, "..", "_build", "dev"); + + const loader = { + ".js": "js", + ".ts": "ts", + ".tsx": "tsx", + ".css": "css", + ".json": "json", + ".svg": "file", + ".png": "file", + ".jpg": "file", + ".gif": "file", + }; - :error -> - case Igniter.Code.Module.move_to_defmodule(zipper) do - {:ok, zipper} -> - case Igniter.Code.Common.move_to_do_block(zipper) do - {:ok, zipper} -> - index_function_code = - quote do - def index(conn, _params) do - render(conn, :index) - end - end + const plugins = [vuePlugin()]; + + let opts = { + entryPoints: ["js/index.ts", "js/app.js"], + bundle: true, + target: "es2020", + outdir: "../priv/static/assets", + logLevel: "info", + loader, + plugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + external: ["/fonts/*", "/images/*"], + splitting: true, + format: "esm", + }; - Igniter.Code.Common.add_code(zipper, index_function_code) + if (deploy) { + opts = { + ...opts, + minify: true, + }; + } - :error -> - zipper - end + if (watch) { + opts = { + ...opts, + sourcemap: "linked", + }; + esbuild.context(opts).then((ctx) => { + ctx.watch(); + }); + } else { + esbuild.build(opts); + } + """ - :error -> - zipper - end - end - end) - end + igniter + |> Igniter.create_new_file("assets/build.js", build_script, on_exists: :warning) end - defp create_index_template(igniter, web_module) do + defp create_esbuild_script(igniter, "svelte") do + build_script = """ + const esbuild = require("esbuild"); + const sveltePlugin = require("esbuild-svelte"); + const path = require("path"); + + const args = process.argv.slice(2); + const watch = args.includes("--watch"); + const deploy = args.includes("--deploy"); + const mixBuildPath = + process.env.MIX_BUILD_PATH || + path.resolve(__dirname, "..", "_build", process.env.MIX_ENV || "dev"); + const fallbackDevBuildPath = path.resolve(__dirname, "..", "_build", "dev"); + + const loader = { + ".js": "js", + ".ts": "ts", + ".tsx": "tsx", + ".css": "css", + ".json": "json", + ".svg": "file", + ".png": "file", + ".jpg": "file", + ".gif": "file", + }; + + const plugins = [ + sveltePlugin({ + compilerOptions: { css: "injected" }, + }), + ]; + + let opts = { + entryPoints: ["js/index.ts", "js/app.js"], + bundle: true, + target: "es2020", + outdir: "../priv/static/assets", + logLevel: "info", + loader, + plugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + external: ["/fonts/*", "/images/*"], + mainFields: ["svelte", "browser", "module", "main"], + conditions: ["svelte", "browser"], + splitting: true, + format: "esm", + }; + + if (deploy) { + opts = { + ...opts, + minify: true, + }; + } + + if (watch) { + opts = { + ...opts, + sourcemap: "linked", + }; + esbuild.context(opts).then((ctx) => { + ctx.watch(); + }); + } else { + esbuild.build(opts); + } + """ + + igniter + |> Igniter.create_new_file("assets/build.js", build_script, on_exists: :warning) + end + + defp create_esbuild_script(igniter, "solid") do + build_script = """ + const esbuild = require("esbuild"); + const { solidPlugin } = require("esbuild-plugin-solid"); + const path = require("path"); + + const args = process.argv.slice(2); + const watch = args.includes("--watch"); + const deploy = args.includes("--deploy"); + const mixBuildPath = + process.env.MIX_BUILD_PATH || + path.resolve(__dirname, "..", "_build", process.env.MIX_ENV || "dev"); + const fallbackDevBuildPath = path.resolve(__dirname, "..", "_build", "dev"); + + const loader = { + ".js": "js", + ".ts": "ts", + ".tsx": "tsx", + ".css": "css", + ".json": "json", + ".svg": "file", + ".png": "file", + ".jpg": "file", + ".gif": "file", + }; + + const plugins = [solidPlugin()]; + + let opts = { + entryPoints: ["js/index.tsx", "js/app.js"], + bundle: true, + target: "es2020", + outdir: "../priv/static/assets", + logLevel: "info", + loader, + plugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + external: ["/fonts/*", "/images/*"], + splitting: true, + format: "esm", + }; + + if (deploy) { + opts = { + ...opts, + minify: true, + }; + } + + if (watch) { + opts = { + ...opts, + sourcemap: "linked", + }; + esbuild.context(opts).then((ctx) => { + ctx.watch(); + }); + } else { + esbuild.build(opts); + } + """ + + igniter + |> Igniter.create_new_file("assets/build.js", build_script, on_exists: :warning) + end + + defp update_esbuild_config_with_script(igniter, app_name, use_bun, _framework) do + npm_install_task = + if use_bun, do: "ash_typescript.npm_install --bun", else: "ash_typescript.npm_install" + + # For Vue/Svelte we use a custom build.js with esbuild's JS API (needed for plugins). + # The vendored esbuild Elixir package is no longer needed since esbuild is installed + # via npm instead, so we remove the vendored config and dep to avoid confusion. + # We need to: + # 1. Add esbuild as an npm dependency (replaces the vendored Elixir esbuild package) + # 2. Remove the vendored esbuild config from config.exs + # 3. Remove the :esbuild dep from mix.exs + # 4. Update dev.exs watchers to use node/bun build.js --watch + # 5. Update assets.setup/build/deploy aliases + + igniter + |> add_esbuild_npm_dep() + |> Igniter.Project.Config.remove_application_configuration("config.exs", :esbuild) + |> Igniter.Project.Deps.remove_dep(:esbuild) + |> remove_esbuild_install_from_assets_setup() + |> Igniter.Project.TaskAliases.add_alias("assets.setup", npm_install_task, + if_exists: :append + ) + |> update_dev_watcher_for_build_script(app_name, use_bun) + |> update_build_aliases_for_script(app_name, use_bun) + end + + defp remove_esbuild_install_from_assets_setup(igniter) do + Igniter.Project.TaskAliases.modify_existing_alias(igniter, "assets.setup", fn zipper -> + Igniter.Code.List.remove_from_list(zipper, fn item_zipper -> + case Sourceror.Zipper.node(item_zipper) do + {:__block__, _, [str]} when is_binary(str) -> + String.contains?(str, "esbuild.install") + + str when is_binary(str) -> + String.contains?(str, "esbuild.install") + + _ -> + false + end + end) + end) + end + + defp add_esbuild_npm_dep(igniter) do + update_package_json(igniter, fn package_json -> + merge_package_section(package_json, "devDependencies", %{"esbuild" => "^0.24.0"}) + end) + end + + defp update_dev_watcher_for_build_script(igniter, app_name, use_bun) do + runner = if use_bun, do: "bun", else: "node" + + {igniter, endpoint} = Igniter.Libs.Phoenix.select_endpoint(igniter) + + endpoint = + case endpoint do + nil -> + # Fallback: construct the endpoint module name from the web module + web_module = Igniter.Libs.Phoenix.web_module(igniter) + Module.concat(web_module, Endpoint) + + endpoint -> + endpoint + end + + Igniter.Project.Config.configure( + igniter, + "dev.exs", + app_name, + [endpoint, :watchers], + {:code, + Sourceror.parse_string!(""" + [ + #{runner}: ["build.js", "--watch", + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")} + ], + tailwind: {Tailwind, :install_and_run, [:#{app_name}, ~w(--watch)]} + ] + """)}, + updater: fn zipper -> + {:ok, + Igniter.Code.Common.replace_code( + zipper, + Sourceror.parse_string!(""" + [ + #{runner}: ["build.js", "--watch", + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Enum.join([Path.expand("../deps", __DIR__), Path.expand(Mix.Project.build_path()), Path.expand("../_build/dev", __DIR__)], ":")} + ], + tailwind: {Tailwind, :install_and_run, [:#{app_name}, ~w(--watch)]} + ] + """) + )} + end + ) + end + + defp update_build_aliases_for_script(igniter, app_name, use_bun) do + runner = if use_bun, do: "bun", else: "node" + + igniter + |> Igniter.Project.TaskAliases.modify_existing_alias("assets.build", fn zipper -> + alias_code = + Sourceror.parse_string!( + ~s|["tailwind #{app_name}", "cmd --cd assets #{runner} build.js"]| + ) + + {:ok, Igniter.Code.Common.replace_code(zipper, alias_code)} + end) + |> Igniter.Project.TaskAliases.modify_existing_alias("assets.deploy", fn zipper -> + alias_code = + Sourceror.parse_string!( + ~s|["tailwind #{app_name} --minify", "cmd --cd assets #{runner} build.js --deploy", "phx.digest"]| + ) + + {:ok, Igniter.Code.Common.replace_code(zipper, alias_code)} + end) + end + + defp update_vite_config_with_framework(igniter, "vue") do + Igniter.update_file(igniter, "assets/vite.config.mjs", fn source -> + content = source.content + + updated_content = + if String.contains?(content, "@vitejs/plugin-vue") do + replace_spa_vite_input_config(content, "js/index.ts") + else + content + |> String.replace( + ~s|import { defineConfig } from 'vite'|, + ~s|import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'| + ) + |> String.replace( + ~s|plugins: [|, + ~s|plugins: [\n vue(),| + ) + |> replace_spa_vite_input_config("js/index.ts") + end + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + defp update_vite_config_with_framework(igniter, "svelte") do + Igniter.update_file(igniter, "assets/vite.config.mjs", fn source -> + content = source.content + + updated_content = + if String.contains?(content, "@sveltejs/vite-plugin-svelte") do + replace_spa_vite_input_config(content, "js/index.ts") + else + content + |> String.replace( + ~s|import { defineConfig } from 'vite'|, + ~s|import { defineConfig } from 'vite'\nimport { svelte } from '@sveltejs/vite-plugin-svelte'| + ) + |> String.replace( + ~s|plugins: [|, + ~s|plugins: [\n svelte(),| + ) + |> replace_spa_vite_input_config("js/index.ts") + end + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + defp update_vite_config_with_framework(igniter, "react") do + Igniter.update_file(igniter, "assets/vite.config.mjs", fn source -> + content = source.content + + updated_content = + if String.contains?(content, "@vitejs/plugin-react") do + replace_spa_vite_input_config(content, "js/index.tsx") + else + content + |> String.replace( + ~s|import { defineConfig } from 'vite'|, + ~s|import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'| + ) + |> String.replace( + ~s|plugins: [|, + ~s|plugins: [\n react(),| + ) + |> replace_spa_vite_input_config("js/index.tsx") + end + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + defp update_vite_config_with_framework(igniter, "solid") do + Igniter.update_file(igniter, "assets/vite.config.mjs", fn source -> + content = source.content + + updated_content = + if String.contains?(content, "vite-plugin-solid") do + replace_spa_vite_input_config(content, "js/index.tsx") + else + content + |> String.replace( + ~s|import { defineConfig } from 'vite'|, + ~s|import { defineConfig } from 'vite'\nimport solid from 'vite-plugin-solid'| + ) + |> String.replace( + ~s|plugins: [|, + ~s|plugins: [\n solid(),| + ) + |> replace_spa_vite_input_config("js/index.tsx") + end + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + # Vite manifest keys are source entry paths (js/index.ts / js/index.tsx). + defp replace_spa_vite_input_config(content, spa_entry) do + input_config = ~s|input: ["#{spa_entry}", "js/app.js", "css/app.css"]| + + content + |> String.replace( + ~s|input: ["js/app.js", "css/app.css"]|, + input_config + ) + |> String.replace( + ~s|input: ["js/index.ts", "js/app.js", "css/app.css"]|, + input_config + ) + |> String.replace( + ~s|input: ["js/index.tsx", "js/app.js", "css/app.css"]|, + input_config + ) + |> String.replace( + ~s|input: {"js/index.js": "js/index.ts", "js/app.js": "js/app.js", "css/app.css": "css/app.css"}|, + input_config + ) + |> String.replace( + ~s|input: {"js/index.js": "js/index.tsx", "js/app.js": "js/app.js", "css/app.css": "css/app.css"}|, + input_config + ) + end + + defp render_install_template(template_name, replacements \\ %{}) do + priv_dir = + case :code.priv_dir(:ash_typescript) do + path when is_list(path) -> to_string(path) + {:error, _reason} -> Path.expand("../../../priv", __DIR__) + end + + template = + priv_dir + |> Path.join("templates/install/#{template_name}") + |> File.read!() + + Enum.reduce(replacements, template, fn {placeholder, value}, acc -> + String.replace(acc, placeholder, value) + end) + end + + # Create spa_root.html.heex layout for vite + react (includes React Refresh preamble) + defp create_spa_root_layout(igniter, web_module, "vite", framework) + when framework in ["react", "react18"] do + app_name = Igniter.Project.Application.app_name(igniter) + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + layout_path = "lib/#{web_path}/components/layouts/spa_root.html.heex" + + layout_content = + render_install_template("spa_root_vite_react.html.heex", %{ + "__WEB_MODULE__" => clean_web_module, + "__APP_NAME__" => to_string(app_name), + "__ENTRY_FILE__" => get_entry_file(framework) + }) + + igniter + |> Igniter.create_new_file(layout_path, layout_content, on_exists: :warning) + end + + # Create spa_root.html.heex layout for vite + vue/svelte (no React Refresh needed) + defp create_spa_root_layout(igniter, web_module, "vite", framework) do + app_name = Igniter.Project.Application.app_name(igniter) + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + layout_path = "lib/#{web_path}/components/layouts/spa_root.html.heex" + + layout_content = + render_install_template("spa_root_vite.html.heex", %{ + "__WEB_MODULE__" => clean_web_module, + "__APP_NAME__" => to_string(app_name), + "__ENTRY_FILE__" => get_entry_file(framework) + }) + + igniter + |> Igniter.create_new_file(layout_path, layout_content, on_exists: :warning) + end + + # Create spa_root.html.heex layout for esbuild (SPA pages) + defp create_spa_root_layout(igniter, web_module, "esbuild", _framework) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + layout_path = "lib/#{web_path}/components/layouts/spa_root.html.heex" + + layout_content = render_install_template("spa_root_esbuild.html.heex") + + igniter + |> Igniter.create_new_file(layout_path, layout_content, on_exists: :warning) + end + + defp create_spa_root_layout(igniter, _web_module, _bundler, _framework), do: igniter + + # Update root.html.heex for esbuild ESM output + # Since esbuild outdir changes from /assets/js to /assets, we need to: + # 1. Update the JS path from /assets/js/app.js to /assets/app.js + # 2. Change script type from text/javascript to module (for ESM format) + defp update_root_layout_for_esbuild(igniter) do + web_module = Igniter.Libs.Phoenix.web_module(igniter) + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + root_layout_path = "lib/#{web_path}/components/layouts/root.html.heex" + + igniter + |> Igniter.update_file(root_layout_path, fn source -> + content = source.content + + updated_content = + content + # Update JS path: /assets/js/app.js -> /assets/app.js + |> String.replace( + ~s|src={~p"/assets/js/app.js"}|, + ~s|src={~p"/assets/app.js"}| + ) + # Update script type: text/javascript -> module (for ESM) + |> String.replace( + ~s|type="text/javascript"|, + ~s|type="module"| + ) + + if content == updated_content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + # For vite, use put_root_layout to use spa_root layout + defp create_or_update_page_controller(igniter, web_module, "vite") do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + + controller_path = + clean_web_module + |> String.replace_suffix("Web", "") + |> Macro.underscore() + + page_controller_path = "lib/#{controller_path}_web/controllers/page_controller.ex" + + page_controller_content = """ + defmodule #{clean_web_module}.PageController do + use #{clean_web_module}, :controller + + def index(conn, _params) do + conn + |> put_root_layout(html: {#{clean_web_module}.Layouts, :spa_root}) + |> render(:index) + end + end + """ + + case Igniter.exists?(igniter, page_controller_path) do + false -> + igniter + |> Igniter.create_new_file(page_controller_path, page_controller_content) + + true -> + igniter + |> Igniter.update_elixir_file(page_controller_path, fn zipper -> + case Igniter.Code.Common.move_to(zipper, &function_named?(&1, :index, 2)) do + {:ok, _zipper} -> + zipper + + :error -> + case Igniter.Code.Module.move_to_defmodule(zipper) do + {:ok, zipper} -> + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + index_function_code = + quote do + def index(conn, _params) do + conn + |> put_root_layout( + html: + {unquote(Module.concat([clean_web_module, Layouts])), :spa_root} + ) + |> render(:index) + end + end + + Igniter.Code.Common.add_code(zipper, index_function_code) + + :error -> + zipper + end + + :error -> + zipper + end + end + end) + end + end + + # For esbuild, use simple render without layout change + # For esbuild, use put_root_layout to switch to spa_root layout + defp create_or_update_page_controller(igniter, web_module, "esbuild") do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + + controller_path = + clean_web_module + |> String.replace_suffix("Web", "") + |> Macro.underscore() + + page_controller_path = "lib/#{controller_path}_web/controllers/page_controller.ex" + + page_controller_content = """ + defmodule #{clean_web_module}.PageController do + use #{clean_web_module}, :controller + + def index(conn, _params) do + conn + |> put_root_layout(html: {#{clean_web_module}.Layouts, :spa_root}) + |> render(:index) + end + end + """ + + case Igniter.exists?(igniter, page_controller_path) do + false -> + igniter + |> Igniter.create_new_file(page_controller_path, page_controller_content) + + true -> + igniter + |> Igniter.update_elixir_file(page_controller_path, fn zipper -> + case Igniter.Code.Common.move_to(zipper, &function_named?(&1, :index, 2)) do + {:ok, _zipper} -> + zipper + + :error -> + case Igniter.Code.Module.move_to_defmodule(zipper) do + {:ok, zipper} -> + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + index_function_code = + quote do + def index(conn, _params) do + conn + |> put_root_layout( + html: + {unquote(Module.concat([clean_web_module, Layouts])), :spa_root} + ) + |> render(:index) + end + end + + Igniter.Code.Common.add_code(zipper, index_function_code) + + :error -> + zipper + end + + :error -> + zipper + end + end + end) + end + end + + defp create_or_update_page_controller(igniter, web_module, _bundler) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + + controller_path = + clean_web_module + |> String.replace_suffix("Web", "") + |> Macro.underscore() + + page_controller_path = "lib/#{controller_path}_web/controllers/page_controller.ex" + + page_controller_content = """ + defmodule #{clean_web_module}.PageController do + use #{clean_web_module}, :controller + + def index(conn, _params) do + render(conn, :index) + end + end + """ + + case Igniter.exists?(igniter, page_controller_path) do + false -> + igniter + |> Igniter.create_new_file(page_controller_path, page_controller_content) + + true -> + igniter + |> Igniter.update_elixir_file(page_controller_path, fn zipper -> + case Igniter.Code.Common.move_to(zipper, &function_named?(&1, :index, 2)) do + {:ok, _zipper} -> + zipper + + :error -> + case Igniter.Code.Module.move_to_defmodule(zipper) do + {:ok, zipper} -> + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + index_function_code = + quote do + def index(conn, _params) do + render(conn, :index) + end + end + + Igniter.Code.Common.add_code(zipper, index_function_code) + + :error -> + zipper + end + + :error -> + zipper + end + end + end) + end + end + + defp create_index_template(igniter, web_module, "vite", _framework) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + index_template_path = "lib/#{web_path}/controllers/page_html/index.html.heex" + + # For vite, assets are loaded via spa_root.html.heex layout + # This template just needs the app mount point + index_template_content = """ +
+ """ + + igniter + |> Igniter.create_new_file(index_template_path, index_template_content, on_exists: :warning) + end + + defp create_index_template(igniter, web_module, "esbuild", _framework) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + index_template_path = "lib/#{web_path}/controllers/page_html/index.html.heex" + + # For esbuild, assets are loaded via spa_root.html.heex layout + # This template just needs the app mount point + index_template_content = """ +
+ """ + + igniter + |> Igniter.create_new_file(index_template_path, index_template_content, on_exists: :warning) + end + + defp create_index_template(igniter, web_module, _bundler, _framework) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + index_template_path = "lib/#{web_path}/controllers/page_html/index.html.heex" + + # Default template for other bundlers + index_template_content = """ +
+ + """ + + igniter + |> Igniter.create_new_file(index_template_path, index_template_content, on_exists: :warning) + end + + defp add_page_index_route(igniter, web_module) do + {igniter, router_module} = Igniter.Libs.Phoenix.select_router(igniter) + + case Igniter.Project.Module.find_module(igniter, router_module) do + {:ok, {igniter, source, _zipper}} -> + router_content = Rewrite.Source.get(source, :content) + route_exists = String.contains?(router_content, "get \"/ash-typescript\"") + + if route_exists do + igniter + else + route_string = " get \"/ash-typescript\", PageController, :index" + + igniter + |> Igniter.Libs.Phoenix.append_to_scope("/", route_string, + arg2: web_module, + placement: :after + ) + end + + {:error, igniter} -> + Igniter.add_warning( + igniter, + "Could not find router module #{inspect(router_module)}. " <> + "Please manually add the /ash-typescript route to your router." + ) + end + end + + defp function_named?(zipper, name, arity) do + case Sourceror.Zipper.node(zipper) do + {:def, _, [{^name, _, args}, _]} when length(args) == arity -> true + _ -> false + end + end + + # ---- Inertia.js Support Functions ---- + + # Add {:inertia, "~> 2.6.0"} to mix.exs + defp add_inertia_dep(igniter) do + Igniter.Project.Deps.add_dep(igniter, {:inertia, "~> 2.6.0"}) + end + + # Add @inertiajs/react (or vue/svelte) npm dependency to package.json + defp add_inertia_deps(igniter, framework) do + inertia_pkg = get_inertia_npm_package(framework) + + update_package_json(igniter, fn package_json -> + merge_package_section(package_json, "dependencies", %{inertia_pkg => "^2.0.0"}) + end) + end + + defp get_inertia_npm_package(framework) when framework in ["react", "react18"], + do: "@inertiajs/react" + + defp get_inertia_npm_package("vue"), do: "@inertiajs/vue3" + defp get_inertia_npm_package("svelte"), do: "@inertiajs/svelte" + defp get_inertia_npm_package(_), do: "@inertiajs/react" + + # Add config :inertia block to config.exs + defp add_inertia_config(igniter, web_module) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + + igniter + |> Igniter.Project.Config.configure_new( + "config.exs", + :inertia, + [:endpoint], + {:code, Sourceror.parse_string!("#{clean_web_module}.Endpoint")} + ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :inertia, + [:static_paths], + ["/assets/index.js"] + ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :inertia, + [:default_version], + "1" + ) + |> Igniter.Project.Config.configure_new( + "config.exs", + :inertia, + [:camelize_props], + false + ) + end + + # Add import Inertia.Controller to controller helper and import Inertia.HTML to html helper + defp setup_inertia_web_module(igniter, web_module) do + {igniter, source, _zipper} = + case Igniter.Project.Module.find_module(igniter, web_module) do + {:ok, result} -> result + {:error, igniter} -> {igniter, nil, nil} + end + + if is_nil(source) do + Igniter.add_warning( + igniter, + "Could not find web module #{inspect(web_module)}. " <> + "Please manually add `import Inertia.Controller` to your controller helper " <> + "and `import Inertia.HTML` to your html helper." + ) + else + web_content = Rewrite.Source.get(source, :content) + + # Add import Inertia.Controller to the controller function + updated_content = + if String.contains?(web_content, "Inertia.Controller") do + web_content + else + String.replace( + web_content, + "import Plug.Conn\n\n unquote(verified_routes())", + "import Plug.Conn\n import Inertia.Controller\n\n unquote(verified_routes())" + ) + end + + # Add import Inertia.HTML to the html_helpers function + updated_content = + if String.contains?(updated_content, "Inertia.HTML") do + updated_content + else + String.replace( + updated_content, + "import Phoenix.HTML", + "import Phoenix.HTML\n import Inertia.HTML" + ) + end + + if web_content == updated_content do + igniter + else + path = Rewrite.Source.get(source, :path) + + Igniter.update_file(igniter, path, fn source -> + Rewrite.Source.update(source, :content, updated_content) + end) + end + end + end + + # Add plug Inertia.Plug to the browser pipeline in router + defp add_inertia_plug_to_router(igniter) do + {igniter, router_module} = Igniter.Libs.Phoenix.select_router(igniter) + + case Igniter.Project.Module.find_module(igniter, router_module) do + {:ok, {igniter, source, _zipper}} -> + router_content = Rewrite.Source.get(source, :content) + + if String.contains?(router_content, "Inertia.Plug") do + igniter + else + path = Rewrite.Source.get(source, :path) + + Igniter.update_file(igniter, path, fn source -> + content = source.content + + # Insert plug Inertia.Plug after plug :put_secure_browser_headers + # It must come after fetch_session, protect_from_forgery, etc. + updated_content = + String.replace( + content, + "plug :put_secure_browser_headers", + "plug :put_secure_browser_headers\n plug Inertia.Plug" + ) + + if content == updated_content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + {:error, igniter} -> + Igniter.add_warning( + igniter, + "Could not find router module #{inspect(router_module)}. " <> + "Please manually add `plug Inertia.Plug` to your browser pipeline." + ) + end + end + + # Setup framework bundler for inertia (handles inertia entry point instead of index entry point) + defp setup_framework_bundler_for_inertia(igniter, app_name, "esbuild", use_bun, framework) + when framework in ["vue", "svelte"] do + # Vue and Svelte need custom build scripts with esbuild plugins + # For Inertia, we add inertia entry point to build.js + igniter + |> create_esbuild_script_for_inertia(framework) + |> update_esbuild_config_with_script(app_name, use_bun, framework) + |> update_root_layout_for_esbuild() + end + + defp setup_framework_bundler_for_inertia(igniter, app_name, "esbuild", use_bun, framework) do + # React/React18 use vendored esbuild CLI directly + igniter + |> update_esbuild_config_for_inertia(app_name, use_bun, framework) + |> update_root_layout_for_esbuild() + end + + defp setup_framework_bundler_for_inertia(igniter, _app_name, _bundler, _use_bun, _framework), + do: igniter + + # Shared Inertia SSR setup for esbuild installs. + # React/React18 use the built-in Elixir esbuild profile. + defp setup_inertia_ssr_support(igniter, app_name, web_module, "esbuild", framework) + when framework in ["react", "react18"] do + igniter + |> configure_inertia_ssr_esbuild_profile(framework) + |> add_inertia_ssr_dev_watcher(app_name) + |> add_inertia_ssr_mix_aliases() + |> add_inertia_ssr_gitignore_entry() + |> add_inertia_ssr_application_child(app_name, web_module) + |> enable_inertia_ssr_config() + end + + # Vue/Svelte use custom plugin-based build.js scripts, so they only need + # runtime wiring here (their SSR build is configured in build.js itself). + defp setup_inertia_ssr_support(igniter, app_name, web_module, "esbuild", framework) + when framework in ["vue", "svelte"] do + igniter + |> add_inertia_ssr_gitignore_entry() + |> add_inertia_ssr_application_child(app_name, web_module) + |> enable_inertia_ssr_config() + end + + defp setup_inertia_ssr_support(igniter, _app_name, _web_module, _bundler, _framework), + do: igniter + + defp configure_inertia_ssr_esbuild_profile(igniter, framework) do + ssr_entry = + if framework in ["react", "react18"], + do: "js/ssr.tsx", + else: "js/ssr.ts" + + ssr_profile = + """ + [ + args: ~w(#{ssr_entry} --bundle --platform=node --outdir=../priv --format=cjs), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + """ + + ssr_profile_ast = Sourceror.parse_string!(ssr_profile) + + Igniter.Project.Config.configure( + igniter, + "config.exs", + :esbuild, + [:ssr], + {:code, ssr_profile_ast}, + updater: fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, ssr_profile_ast)} + end + ) + end + + defp add_inertia_ssr_dev_watcher(igniter, app_name) do + {igniter, endpoint} = Igniter.Libs.Phoenix.select_endpoint(igniter) + + endpoint = + case endpoint do + nil -> + web_module = Igniter.Libs.Phoenix.web_module(igniter) + Module.concat(web_module, Endpoint) + + endpoint -> + endpoint + end + + default_watchers = + Sourceror.parse_string!(""" + [ + esbuild: {Esbuild, :install_and_run, [#{inspect(app_name)}, ~w(--sourcemap=inline --watch)]}, + ssr: {Esbuild, :install_and_run, [:ssr, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [#{inspect(app_name)}, ~w(--watch)]} + ] + """) + + ssr_watcher = + Sourceror.parse_string!( + "{Esbuild, :install_and_run, [:ssr, ~w(--sourcemap=inline --watch)]}" + ) + + Igniter.Project.Config.configure( + igniter, + "dev.exs", + app_name, + [endpoint, :watchers], + {:code, default_watchers}, + updater: fn zipper -> + Igniter.Code.Keyword.set_keyword_key(zipper, :ssr, ssr_watcher, &{:ok, &1}) + end + ) + end + + defp add_inertia_ssr_mix_aliases(igniter) do + igniter + |> Igniter.Project.TaskAliases.add_alias("assets.build", "esbuild ssr", if_exists: :append) + |> Igniter.Project.TaskAliases.add_alias("assets.deploy", "esbuild ssr", if_exists: :append) + end + + defp add_inertia_ssr_gitignore_entry(igniter) do + if Igniter.exists?(igniter, ".gitignore") do + Igniter.update_file(igniter, ".gitignore", fn source -> + content = source.content + + if String.contains?(content, "/priv/ssr.js") do + source + else + updated_content = + if String.ends_with?(content, "\n") do + content <> "/priv/ssr.js\n" + else + content <> "\n/priv/ssr.js\n" + end + + Rewrite.Source.update(source, :content, updated_content) + end + end) + else + Igniter.create_new_file(igniter, ".gitignore", "/priv/ssr.js\n") + end + end + + defp add_inertia_ssr_application_child(igniter, app_name, web_module) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + app_module = String.replace_suffix(clean_web_module, "Web", "") + app_path = Macro.underscore(app_module) + application_path = "lib/#{app_path}/application.ex" + + ssr_child = + " {Inertia.SSR, path: Path.join([Application.app_dir(:#{app_name}), \"priv\"])}" + + endpoint_child = " #{clean_web_module}.Endpoint" + + Igniter.update_file(igniter, application_path, fn source -> + content = source.content + + if String.contains?(content, "Inertia.SSR") do + source + else + updated_content = + String.replace( + content, + endpoint_child, + ssr_child <> ",\n" <> endpoint_child, + global: false + ) + + if updated_content == content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end + end) + end + + defp enable_inertia_ssr_config(igniter) do + raise_setting = Sourceror.parse_string!("config_env() != :prod") + + igniter + |> Igniter.Project.Config.configure( + "config.exs", + :inertia, + [:ssr], + true, + updater: fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, true)} + end + ) + |> Igniter.Project.Config.configure( + "config.exs", + :inertia, + [:raise_on_ssr_failure], + {:code, raise_setting}, + updater: fn zipper -> + {:ok, Igniter.Code.Common.replace_code(zipper, raise_setting)} + end + ) + end + + # Update esbuild config for Inertia (adds inertia entry file instead of index) + defp update_esbuild_config_for_inertia(igniter, app_name, use_bun, framework) do + npm_install_task = + if use_bun, do: "ash_typescript.npm_install --bun", else: "ash_typescript.npm_install" + + entry_file = get_inertia_entry_file(framework) + + igniter + |> Igniter.Project.TaskAliases.add_alias("assets.setup", npm_install_task, + if_exists: :append + ) + |> Igniter.update_elixir_file("config/config.exs", fn zipper -> + is_esbuild_node = fn + {:config, _, [{:__block__, _, [:esbuild]} | _rest]} -> true + _ -> false + end + + is_app_node = fn + {{:__block__, _, [^app_name]}, _} -> true + _ -> false + end + + {:ok, zipper} = + Igniter.Code.Common.move_to(zipper, fn zipper -> + zipper + |> Sourceror.Zipper.node() + |> is_esbuild_node.() + end) + + {:ok, zipper} = + Igniter.Code.Common.move_to(zipper, fn zipper -> + zipper + |> Sourceror.Zipper.node() + |> is_app_node.() + end) + + is_args_node = fn + {{:__block__, _, [:args]}, {:sigil_w, _, _}} -> true + _ -> false + end + + {:ok, zipper} = + Igniter.Code.Common.move_to(zipper, fn zipper -> + zipper + |> Sourceror.Zipper.node() + |> is_args_node.() + end) + + args_node = Sourceror.Zipper.node(zipper) + + case args_node do + {{:__block__, block_meta, [:args]}, + {:sigil_w, sigil_meta, [{:<<>>, string_meta, [args_string]}, sigil_opts]}} -> + # Add inertia entry file + new_args_string = + if String.contains?(args_string, entry_file) do + args_string + else + entry_file <> " " <> args_string + end + + # Change output directory from assets/js to assets + new_args_string = + String.replace( + new_args_string, + "--outdir=../priv/static/assets/js", + "--outdir=../priv/static/assets" + ) + + # Add code splitting flags + new_args_string = + if String.contains?(new_args_string, "--splitting") do + new_args_string + else + new_args_string <> " --splitting" + end + + new_args_string = + if String.contains?(new_args_string, "--format=esm") do + new_args_string + else + new_args_string <> " --format=esm" + end + + new_args_node = + {{:__block__, block_meta, [:args]}, + {:sigil_w, sigil_meta, [{:<<>>, string_meta, [new_args_string]}, sigil_opts]}} + + Sourceror.Zipper.replace(zipper, new_args_node) + + _ -> + zipper + end + end) + |> normalize_esbuild_node_path_env() + end + + defp get_inertia_entry_file(framework) when framework in ["react", "react18"], + do: "js/index.tsx" + + defp get_inertia_entry_file(_), do: "js/index.ts" + + defp get_inertia_ssr_entry_file(framework) when framework in ["react", "react18"], + do: "ssr.tsx" + + defp get_inertia_ssr_entry_file(_), do: "ssr.ts" + + # Create esbuild build.js for Vue/Svelte with Inertia entry point + defp create_esbuild_script_for_inertia(igniter, "vue") do + build_script = """ + const esbuild = require("esbuild"); + const vuePlugin = require("esbuild-plugin-vue3"); + const path = require("path"); + + const args = process.argv.slice(2); + const watch = args.includes("--watch"); + const deploy = args.includes("--deploy"); + const mixBuildPath = + process.env.MIX_BUILD_PATH || + path.resolve(__dirname, "..", "_build", process.env.MIX_ENV || "dev"); + const fallbackDevBuildPath = path.resolve(__dirname, "..", "_build", "dev"); + + const loader = { + ".js": "js", + ".ts": "ts", + ".tsx": "tsx", + ".css": "css", + ".json": "json", + ".svg": "file", + ".png": "file", + ".jpg": "file", + ".gif": "file", + }; + + const clientPlugins = [vuePlugin()]; + const ssrPlugins = [vuePlugin({ renderSSR: true })]; + + let clientOpts = { + entryPoints: ["js/index.ts", "js/app.js"], + bundle: true, + target: "es2020", + outdir: "../priv/static/assets", + logLevel: "info", + loader, + plugins: clientPlugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + external: ["/fonts/*", "/images/*"], + splitting: true, + format: "esm", + }; + + let ssrOpts = { + entryPoints: ["js/ssr.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "cjs", + outfile: "../priv/ssr.js", + logLevel: "info", + loader, + plugins: ssrPlugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + }; + + if (deploy) { + clientOpts = { + ...clientOpts, + minify: true, + }; + + ssrOpts = { + ...ssrOpts, + minify: true, + }; + } + + async function run() { + if (watch) { + clientOpts = { + ...clientOpts, + sourcemap: "linked", + }; + + ssrOpts = { + ...ssrOpts, + sourcemap: "linked", + }; + + const [clientCtx, ssrCtx] = await Promise.all([ + esbuild.context(clientOpts), + esbuild.context(ssrOpts), + ]); + + await Promise.all([clientCtx.watch(), ssrCtx.watch()]); + return; + } + + await Promise.all([esbuild.build(clientOpts), esbuild.build(ssrOpts)]); + } + + run().catch((error) => { + console.error(error); + process.exit(1); + }); + """ + + igniter + |> Igniter.create_new_file("assets/build.js", build_script, on_exists: :warning) + end + + defp create_esbuild_script_for_inertia(igniter, "svelte") do + build_script = """ + const esbuild = require("esbuild"); + const sveltePlugin = require("esbuild-svelte"); + const path = require("path"); + + const args = process.argv.slice(2); + const watch = args.includes("--watch"); + const deploy = args.includes("--deploy"); + const mixBuildPath = + process.env.MIX_BUILD_PATH || + path.resolve(__dirname, "..", "_build", process.env.MIX_ENV || "dev"); + const fallbackDevBuildPath = path.resolve(__dirname, "..", "_build", "dev"); + + const loader = { + ".js": "js", + ".ts": "ts", + ".tsx": "tsx", + ".css": "css", + ".json": "json", + ".svg": "file", + ".png": "file", + ".jpg": "file", + ".gif": "file", + }; + + const plugins = [ + sveltePlugin({ + compilerOptions: { css: "injected" }, + }), + ]; + + const ssrPlugins = [ + sveltePlugin({ + compilerOptions: { generate: "server", css: "external" }, + }), + ]; + + let clientOpts = { + entryPoints: ["js/index.ts", "js/app.js"], + bundle: true, + target: "es2020", + outdir: "../priv/static/assets", + logLevel: "info", + loader, + plugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + external: ["/fonts/*", "/images/*"], + mainFields: ["svelte", "browser", "module", "main"], + conditions: ["svelte", "browser"], + splitting: true, + format: "esm", + }; + + let ssrOpts = { + entryPoints: ["js/ssr.ts"], + bundle: true, + platform: "node", + target: "node20", + format: "cjs", + outfile: "../priv/ssr.js", + logLevel: "info", + loader, + plugins: ssrPlugins, + nodePaths: [ + "../deps", + mixBuildPath, + fallbackDevBuildPath, + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + ], + mainFields: ["svelte", "module", "main"], + conditions: ["svelte"], + }; + + if (deploy) { + clientOpts = { + ...clientOpts, + minify: true, + }; + + ssrOpts = { + ...ssrOpts, + minify: true, + }; + } + + async function run() { + if (watch) { + clientOpts = { + ...clientOpts, + sourcemap: "linked", + }; + + ssrOpts = { + ...ssrOpts, + sourcemap: "linked", + }; + + const [clientCtx, ssrCtx] = await Promise.all([ + esbuild.context(clientOpts), + esbuild.context(ssrOpts), + ]); + + await Promise.all([clientCtx.watch(), ssrCtx.watch()]); + return; + } + + await Promise.all([esbuild.build(clientOpts), esbuild.build(ssrOpts)]); + } + + run().catch((error) => { + console.error(error); + process.exit(1); + }); + """ + + igniter + |> Igniter.create_new_file("assets/build.js", build_script, on_exists: :warning) + end + + # Create inertia_root.html.heex layout for esbuild + defp create_inertia_root_layout(igniter, web_module, "esbuild", _framework) do + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + web_path = Macro.underscore(clean_web_module) + layout_path = "lib/#{web_path}/components/layouts/inertia_root.html.heex" + + layout_content = render_install_template("inertia_root_esbuild.html.heex") + + igniter + |> Igniter.create_new_file(layout_path, layout_content, on_exists: :warning) + end + + defp create_inertia_root_layout(igniter, _web_module, _bundler, _framework), do: igniter + + # Create Inertia entry point file (assets/js/index.tsx or .ts) + defp create_inertia_entry_point(igniter, framework, _bundler) + when framework in ["react", "react18"] do + entry_content = """ + import { createInertiaApp } from "@inertiajs/react"; + import { hydrateRoot } from "react-dom/client"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; + + (window as any).Prism = Prism; + + createInertiaApp({ + resolve: async (name) => { + return await import(`./pages/${name}.tsx`); + }, + setup({ el, App, props }) { + hydrateRoot(el, ); + }, + }); + """ + + igniter + |> Igniter.create_new_file("assets/js/index.tsx", entry_content, on_exists: :warning) + end + + defp create_inertia_entry_point(igniter, "vue", _bundler) do + entry_content = """ + import { createSSRApp, h } from "vue"; + import { createInertiaApp } from "@inertiajs/vue3"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; + + (window as any).Prism = Prism; + + createInertiaApp({ + resolve: async (name) => { + return await import(`./pages/${name}.vue`); + }, + setup({ el, App, props, plugin }) { + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .mount(el); + }, + }); + """ + + igniter + |> Igniter.create_new_file("assets/js/index.ts", entry_content, on_exists: :warning) + end + + defp create_inertia_entry_point(igniter, "svelte", _bundler) do + entry_content = """ + import { mount } from "svelte"; + import { hydrate } from "svelte"; + import { createInertiaApp } from "@inertiajs/svelte"; + import Prism from "prismjs"; + import "prismjs/components/prism-elixir"; + import "prismjs/components/prism-bash"; + import "prismjs/components/prism-typescript"; + + (window as any).Prism = Prism; + + createInertiaApp({ + resolve: async (name) => { + return await import(`./pages/${name}.svelte`); + }, + setup({ el, App, props }) { + if (el.dataset.serverRendered === "true") { + hydrate(App, { target: el, props }); + } else { + mount(App, { target: el, props }); + } + }, + }); + """ + + igniter + |> Igniter.create_new_file("assets/js/index.ts", entry_content, on_exists: :warning) + end + + # Create Inertia SSR entry point file (assets/js/ssr.tsx or .ts) + defp create_inertia_ssr_entry_point(igniter, framework) + when framework in ["react", "react18"] do + entry_content = """ + import { createInertiaApp } from "@inertiajs/react"; + import ReactDOMServer from "react-dom/server"; + + export function render(page) { + return createInertiaApp({ + page, + render: ReactDOMServer.renderToString, + resolve: async (name) => { + return await import(`./pages/${name}.tsx`); + }, + setup: ({ App, props }) => , + }); + } + """ + + igniter + |> Igniter.create_new_file("assets/js/ssr.tsx", entry_content, on_exists: :warning) + end + + defp create_inertia_ssr_entry_point(igniter, "vue") do + entry_content = """ + import { createInertiaApp } from "@inertiajs/vue3"; + import { renderToString } from "vue/server-renderer"; + import { createSSRApp, h } from "vue"; + + export function render(page) { + return createInertiaApp({ + page, + render: renderToString, + resolve: async (name) => { + return await import(`./pages/${name}.vue`); + }, + setup({ App, props, plugin }) { + return createSSRApp({ + render: () => h(App, props), + }).use(plugin); + }, + }); + } + """ + + igniter + |> Igniter.create_new_file("assets/js/ssr.ts", entry_content, on_exists: :warning) + end + + defp create_inertia_ssr_entry_point(igniter, "svelte") do + entry_content = """ + import { createInertiaApp } from "@inertiajs/svelte"; + import { render as renderSvelte } from "svelte/server"; + + export function render(page) { + return createInertiaApp({ + page, + resolve: async (name) => { + return await import(`./pages/${name}.svelte`); + }, + setup({ App, props }) { + return renderSvelte(App, { props }); + }, + }); + } + """ + + igniter + |> Igniter.create_new_file("assets/js/ssr.ts", entry_content, on_exists: :warning) + end + + # Create demo page component that demonstrates AshTypescript RPC client + defp create_inertia_page_component(igniter, framework) + when framework in ["react", "react18"] do + page_body = get_react_page_body() + + component_content = """ + import React from "react"; + + declare global { + interface Window { + Prism: any; + } + } + + export default function App() { + React.useEffect(() => { + if (window.Prism) { + window.Prism.highlightAll(); + } + }, []); + + return ( + #{page_body} + ); + } + """ + + igniter + |> Igniter.create_new_file( + "assets/js/pages/App.tsx", + component_content, + on_exists: :warning + ) + end + + defp create_inertia_page_component(igniter, "vue") do + {script_content, template_content} = get_vue_page_content() + + vue_component = script_content <> "\n" <> template_content + + igniter + |> Igniter.create_new_file("assets/js/pages/App.vue", vue_component, on_exists: :warning) + end + + defp create_inertia_page_component(igniter, "svelte") do + {script_content, template_content} = get_svelte_page_content() + + svelte_component = script_content <> "\n" <> template_content + + igniter + |> Igniter.create_new_file("assets/js/pages/App.svelte", svelte_component, + on_exists: :warning + ) + end + + # Create PageController that uses render_inertia + defp create_inertia_page_controller(igniter, web_module, _framework) do clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + page_name = "App" + + controller_path = + clean_web_module + |> String.replace_suffix("Web", "") + |> Macro.underscore() + + page_controller_path = "lib/#{controller_path}_web/controllers/page_controller.ex" + + page_controller_content = """ + defmodule #{clean_web_module}.PageController do + use #{clean_web_module}, :controller + + def index(conn, _params) do + render_inertia(conn, "#{page_name}") + end + end + """ + + case Igniter.exists?(igniter, page_controller_path) do + false -> + igniter + |> Igniter.create_new_file(page_controller_path, page_controller_content) + + true -> + igniter + |> Igniter.update_elixir_file(page_controller_path, fn zipper -> + case Igniter.Code.Common.move_to(zipper, &function_named?(&1, :index, 2)) do + {:ok, _zipper} -> + # index function already exists, don't modify + zipper + + :error -> + case Igniter.Code.Module.move_to_defmodule(zipper) do + {:ok, zipper} -> + case Igniter.Code.Common.move_to_do_block(zipper) do + {:ok, zipper} -> + index_function_code = + quote do + def index(conn, _params) do + render_inertia(conn, unquote(page_name)) + end + end + + Igniter.Code.Common.add_code(zipper, index_function_code) + + :error -> + zipper + end + + :error -> + zipper + end + end + end) + end + end + + # Add :inertia pipeline and nested scope with route to router + defp add_inertia_pipeline_and_routes(igniter, web_module) do + {igniter, router_module} = Igniter.Libs.Phoenix.select_router(igniter) + + case Igniter.Project.Module.find_module(igniter, router_module) do + {:ok, {igniter, source, _zipper}} -> + router_content = Rewrite.Source.get(source, :content) + clean_web_module = web_module |> to_string() |> String.replace_prefix("Elixir.", "") + path = Rewrite.Source.get(source, :path) + + # Add :inertia pipeline if not present + igniter = + if String.contains?(router_content, "pipeline :inertia") do + igniter + else + Igniter.update_file(igniter, path, fn source -> + content = source.content + + # Find the end of the browser pipeline and insert after it + pipeline_code = """ + + pipeline :inertia do + plug :put_root_layout, html: {#{clean_web_module}.Layouts, :inertia_root} + end + """ + + # Insert after the browser pipeline's end + updated_content = + Regex.replace( + ~r/(pipeline :browser do.*?end)/s, + content, + "\\1\n#{pipeline_code}", + global: false + ) + + if content == updated_content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + + # Re-read the source after potential pipeline addition + {igniter, source, _zipper} = + case Igniter.Project.Module.find_module(igniter, router_module) do + {:ok, result} -> result + {:error, igniter} -> {igniter, nil, nil} + end + + if is_nil(source) do + igniter + else + router_content = Rewrite.Source.get(source, :content) + + # Add the nested inertia scope with route if not present + if String.contains?(router_content, "pipe_through :inertia") do + igniter + else + # Add nested scope inside the main browser scope + inertia_scope = """ + scope "/" do + pipe_through :inertia + get "/ash-typescript", PageController, :index + end + """ + + Igniter.update_file(igniter, path, fn source -> + content = source.content + + # Find the browser scope and append the inertia scope inside it + # Look for: scope "/", MyAppWeb do\n pipe_through :browser + updated_content = + Regex.replace( + ~r/(scope "\/", #{Regex.escape(clean_web_module)} do\s*\n\s*pipe_through :browser)/s, + content, + "\\1\n\n#{inertia_scope}", + global: false + ) + + if content == updated_content do + source + else + Rewrite.Source.update(source, :content, updated_content) + end + end) + end + end + + {:error, igniter} -> + Igniter.add_warning( + igniter, + "Could not find router module #{inspect(router_module)}. " <> + "Please manually add the :inertia pipeline and /ash-typescript route." + ) + end + end + + # ---- End Inertia.js Support Functions ---- + + # ---- Shared Page Content Generators ---- + # These functions generate the page content that is shared between SPA and Inertia flows. + # They return the body content without the component wrapper (mount code for SPA, export for Inertia). + + # React page body content (JSX only, no component wrapper) + defp get_react_page_body do + """ +
+
+
+ AshTypescript Logo +
+

+ AshTypescript +

+

+ Type-safe TypeScript bindings for Ash Framework +

+
+
+ +
+
+
+
+ 1 +
+

+ Configure RPC in Your Domain +

+
+

+ Add the AshTypescript.Rpc extension to your domain and configure RPC actions: +

+
+                    
+      {\`defmodule MyApp.Accounts do
+        use Ash.Domain, extensions: [AshTypescript.Rpc]
+
+        typescript_rpc do
+          resource MyApp.Accounts.User do
+            rpc_action :get_by_email, :get_by_email
+            rpc_action :list_users, :read
+            rpc_action :get_user, :read
+          end
+        end
+
+        resources do
+          resource MyApp.Accounts.User
+        end
+      end\`}
+                    
+                  
+
+ +
+
+
+ 2 +
+

+ TypeScript Auto-Generation +

+
+

+ When running the dev server, TypeScript types are automatically generated for you: +

+
+                    mix phx.server
+                  
+
+

+ ✨ Automatic regeneration: TypeScript files are automatically regenerated whenever you make changes to your resources or expose new RPC actions. No manual codegen step required during development! +

+
+

+ For production builds or manual generation, you can also run: +

+
+                    mix ash_typescript.codegen --output "assets/js/ash_generated.ts"
+                  
+
+ +
+
+
+ 3 +
+

+ Import and Use Generated Functions +

+
+

+ Import the generated RPC functions in your TypeScript/React code: +

+
+                    
+      {\`import { getByEmail, listUsers, getUser } from "./ash_generated";
+
+      // Use the typed RPC functions
+      const findUserByEmail = async (email: string) => {
+        try {
+          const result = await getByEmail({ email });
+          if (result.success) {
+            console.log("User found:", result.data);
+            return result.data;
+          } else {
+            console.error("User not found:", result.errors);
+            return null;
+          }
+        } catch (error) {
+          console.error("Network error:", error);
+          return null;
+        }
+      };
+
+      const fetchUsers = async () => {
+        try {
+          const result = await listUsers();
+          if (result.success) {
+            console.log("Users:", result.data);
+          } else {
+            console.error("Failed to fetch users:", result.errors);
+          }
+        } catch (error) {
+          console.error("Network error:", error);
+        }
+      };\`}
+                    
+                  
+
+ +
+

+ Learn More & Examples +

+ +
- index_template_path = "lib/#{web_path}/controllers/page_html/index.html.heex" +
+
+
+ 🚀 +
+
+

+ Ready to Get Started? +

+

+ Check your generated RPC functions and start building type-safe interactions between your frontend and Ash resources! +

+
+
+
+
+ """ + end - index_template_content = """ - - - + # Solid page body content (JSX only, no component wrapper) + defp get_solid_page_body do + get_react_page_body() + |> String.replace("className=", "class=") + end -
- """ - igniter - |> Igniter.create_new_file(index_template_path, index_template_content, on_exists: :warning) + template_content = """ + + """ + + {script_content, template_content} end - defp add_page_index_route(igniter, web_module) do - {igniter, router_module} = Igniter.Libs.Phoenix.select_router(igniter) + # Svelte page script and template content + defp get_svelte_page_content do + script_content = """ + + """ + + template_content = """ +
+
+
+ AshTypescript Logo +
+

AshTypescript

+

+ Type-safe TypeScript bindings for Ash Framework +

+
+
+ +
+
+
+
1
+

Configure RPC in Your Domain

+
+

+ Add the AshTypescript.Rpc extension to your domain and configure RPC actions: +

+
{elixirCode}
+
+ +
+
+
2
+

TypeScript Auto-Generation

+
+

+ When running the dev server, TypeScript types are automatically generated for you: +

+
mix phx.server
+
+

+ ✨ Automatic regeneration: TypeScript files are automatically regenerated whenever you make changes to your resources or expose new RPC actions. No manual codegen step required during development! +

+
+

For production builds or manual generation, you can also run:

+
mix ash_typescript.codegen --output "assets/js/ash_generated.ts"
+
+ +
+
+
3
+

Import and Use Generated Functions

+
+

+ Import the generated RPC functions in your TypeScript/Svelte code: +

+
{typescriptCode}
+
+ +
+

Learn More & Examples

+ +
+ +
+
+
+ 🚀 +
+
+

Ready to Get Started?

+

+ Check your generated RPC functions and start building type-safe interactions between your frontend and Ash resources! +

+
+
+
+
+ """ + + {script_content, template_content} end - defp add_next_steps_notice(igniter, framework) do + # ---- End Shared Page Content Generators ---- + + defp add_next_steps_notice(igniter, framework, bundler, use_inertia) do + inertia_entry_file = + if framework in ["react", "react18"], do: "index.tsx", else: "index.ts" + + inertia_ssr_entry_file = get_inertia_ssr_entry_file(framework) + + inertia_page_file = + if framework in ["react", "react18"], + do: "pages/App.tsx", + else: "pages/App.#{framework}" + base_notice = """ - 🎉 AshTypescript has been successfully installed! + AshTypescript has been successfully installed! Next Steps: 1. Configure your domain with the AshTypescript.Rpc extension @@ -723,28 +3616,151 @@ if Code.ensure_loaded?(Igniter) do 3. Generate TypeScript types with: mix ash_typescript.codegen 4. Start using type-safe RPC functions in your frontend! - 📚 Documentation: https://hexdocs.pm/ash_typescript + Documentation: https://hexdocs.pm/ash_typescript """ - react_notice = """ - 🎉 AshTypescript with React has been successfully installed! + framework_notice_vite = fn name -> + """ + AshTypescript with #{name} + Vite has been successfully installed! - Your Phoenix + React + TypeScript setup is ready! + Your Phoenix + #{name} + TypeScript + Vite setup is ready! - Next Steps: - 1. Configure your domain with the AshTypescript.Rpc extension - 2. Add typescript_rpc configurations for your resources - 3. Start your Phoenix server: mix phx.server - 4. Check out http://localhost:4000/ash-typescript for how to get started! + Files created: + - spa_root.html.heex: Layout for SPA pages (loads the Vite entry + app.css) + - PageController: Uses put_root_layout to switch to spa_root layout - 📚 Documentation: https://hexdocs.pm/ash_typescript - """ + The root.html.heex layout loads app.js + app.css for LiveView pages. + The spa_root.html.heex layout loads your Vite entry (js/index.ts or js/index.tsx) + app.css for SPA pages. + + Next Steps: + 1. Configure your domain with the AshTypescript.Rpc extension + 2. Add typescript_rpc configurations for your resources + 3. Start your Phoenix server: mix phx.server + 4. Check out http://localhost:4000/ash-typescript for how to get started! + + Documentation: https://hexdocs.pm/ash_typescript + """ + end + + framework_notice_esbuild = fn name -> + """ + AshTypescript with #{name} + esbuild has been successfully installed! + + Your Phoenix + #{name} + TypeScript + esbuild setup is ready! + + Files created: + - spa_root.html.heex: Layout for SPA pages (loads index.js as ES module) + - PageController: Uses put_root_layout to switch to spa_root layout + + The root.html.heex layout loads app.js + app.css for LiveView pages. + The spa_root.html.heex layout loads index.js as ES module for SPA pages. + + Next Steps: + 1. Configure your domain with the AshTypescript.Rpc extension + 2. Add typescript_rpc configurations for your resources + 3. Start your Phoenix server: mix phx.server + 4. Check out http://localhost:4000/ash-typescript for how to get started! + + Documentation: https://hexdocs.pm/ash_typescript + """ + end + + inertia_notice_esbuild = fn name -> + """ + AshTypescript with #{name} + Inertia.js + esbuild has been successfully installed! + + Your Phoenix + #{name} + Inertia.js + TypeScript + esbuild setup is ready! + + Files created: + - inertia_root.html.heex: Layout for Inertia pages (loads index.js as ES module) + - #{inertia_entry_file}: Inertia client-side entry point with createInertiaApp() + - #{inertia_ssr_entry_file}: Inertia SSR entry point + - #{inertia_page_file}: Getting started guide page + - PageController: Renders Inertia pages + + The root.html.heex layout loads app.js + app.css for LiveView pages. + The inertia_root.html.heex layout loads index.js as ES module for Inertia pages. + + Next Steps: + 1. Start your Phoenix server: mix phx.server + 2. Visit http://localhost:4000/ash-typescript to see the getting started guide! + 3. Visit http://localhost:4000/ to see LiveView still working! + + Documentation: https://hexdocs.pm/ash_typescript + Inertia.js: https://inertiajs.com + """ + end + + notice = + case {framework, bundler, use_inertia} do + {_, "vite", true} -> + """ + AshTypescript installation completed with warnings. + + Inertia currently supports only `--bundler esbuild`. + Re-run installation with `--bundler esbuild`, or remove `--inertia` to use Vite. + """ + + {_, _, true} -> + name = + case framework do + "react" -> "React" + "react18" -> "React 18" + "vue" -> "Vue" + "svelte" -> "Svelte" + "solid" -> "Solid" + _ -> "React" + end + + inertia_notice_esbuild.(name) + + {"react", "vite", _} -> + framework_notice_vite.("React") + + {"react18", "vite", _} -> + framework_notice_vite.("React 18") + + {"vue", "vite", _} -> + framework_notice_vite.("Vue") + + {"svelte", "vite", _} -> + framework_notice_vite.("Svelte") + + {"solid", "vite", _} -> + framework_notice_vite.("Solid") + + {"react", _, _} -> + framework_notice_esbuild.("React") + + {"react18", _, _} -> + framework_notice_esbuild.("React 18") + + {"vue", _, _} -> + framework_notice_esbuild.("Vue") + + {"svelte", _, _} -> + framework_notice_esbuild.("Svelte") + + {"solid", _, _} -> + framework_notice_esbuild.("Solid") + + _ -> + base_notice + end + + notice = + notice <> + """ - notice = if framework == "react", do: react_notice, else: base_notice + Note: + - The demo page syntax highlighting uses the bundled `prismjs` npm package. + """ + # Run assets.setup to install npm dependencies (including framework deps we added) + # For both esbuild and vite, we need to run this since we add framework deps to package.json igniter = - if framework == "react" do - Igniter.add_task(igniter, "ash_typescript.npm_install") + if framework in ["react", "react18", "vue", "svelte", "solid"] do + Igniter.add_task(igniter, "assets.setup") else igniter end diff --git a/lib/mix/tasks/ash_typescript.npm_install.ex b/lib/mix/tasks/ash_typescript.npm_install.ex index 2d67e9f9..2f4b1ad5 100644 --- a/lib/mix/tasks/ash_typescript.npm_install.ex +++ b/lib/mix/tasks/ash_typescript.npm_install.ex @@ -7,7 +7,14 @@ defmodule Mix.Tasks.AshTypescript.NpmInstall do use Mix.Task @impl true - def run(_) do - System.cmd("npm", ["install"], cd: "assets") + def run(args) do + package_manager = + if args == ["--bun"] do + "bun" + else + "npm" + end + + System.cmd(package_manager, ["install"], cd: "assets") end end diff --git a/mix.exs b/mix.exs index 15ec1e2a..3b0cd56d 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,7 @@ defmodule AshTypescript.MixProject do ], licenses: ["MIT"], files: ~w(lib .formatter.exs mix.exs README* - CHANGELOG* documentation usage-rules.md LICENSES), + CHANGELOG* documentation usage-rules.md LICENSES priv), links: %{ "GitHub" => "https://github.com/ash-project/ash_typescript", "Changelog" => "https://github.com/ash-project/ash_typescript/blob/main/CHANGELOG.md", diff --git a/priv/templates/install/inertia_root_esbuild.html.heex b/priv/templates/install/inertia_root_esbuild.html.heex new file mode 100644 index 00000000..d3529e1a --- /dev/null +++ b/priv/templates/install/inertia_root_esbuild.html.heex @@ -0,0 +1,16 @@ + + + + + + + <.inertia_title>{assigns[:page_title]} + <.inertia_head content={@inertia_head} /> + + + + {@inner_content} + + + diff --git a/priv/templates/install/spa_root_esbuild.html.heex b/priv/templates/install/spa_root_esbuild.html.heex new file mode 100644 index 00000000..db7cdda5 --- /dev/null +++ b/priv/templates/install/spa_root_esbuild.html.heex @@ -0,0 +1,15 @@ + + + + + + + <.live_title default="AshTypescript">Page + + + + {@inner_content} + + + diff --git a/priv/templates/install/spa_root_vite.html.heex b/priv/templates/install/spa_root_vite.html.heex new file mode 100644 index 00000000..08d5e0c2 --- /dev/null +++ b/priv/templates/install/spa_root_vite.html.heex @@ -0,0 +1,18 @@ + + + + + + + <.live_title default="AshTypescript">Page + static_url(@conn, p) end} + /> + + + {@inner_content} + + diff --git a/priv/templates/install/spa_root_vite_react.html.heex b/priv/templates/install/spa_root_vite_react.html.heex new file mode 100644 index 00000000..b6260e56 --- /dev/null +++ b/priv/templates/install/spa_root_vite_react.html.heex @@ -0,0 +1,27 @@ + + + + + + + <.live_title default="AshTypescript">Page + <%= if PhoenixVite.Components.has_vite_watcher?(__WEB_MODULE__.Endpoint) do %> + + <% end %> + static_url(@conn, p) end} + /> + + + {@inner_content} + +