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
-
-
- 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
-
-
-
+ 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
+
+
+ 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 = """
+
+
+
+
+
+
+
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/Vue 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_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
+
+ 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}
+
+