defmodule AshDispatch.TemplateResolver do alias AshDispatch.Config alias AshDispatch.Naming @moduledoc """ Resolves and renders HEEx/EEx templates for events with fallback chain. Supports both development (file-based) and production (priv-based) templates. ## Layout System Templates can use layouts to share common structure (headers, footers, branding). **Layout location:** `priv/ash_dispatch/layouts/` **Layout naming:** `{transport}.{format}` e.g., `email.html.heex`, `email.text.eex` **Content injection:** Use `<%= @inner_content %>` in layouts where event content should appear. Example layout (`priv/ash_dispatch/layouts/email.html.heex`): <%= @subject %>
Your Brand
<%= @inner_content %> ## Compilation Strategy **Development:** Templates are loaded from files for fast iteration. **Production:** Templates are auto-copied during `mix compile` by the `Mix.Tasks.Compile.AshDispatch` compiler. All templates (both convention-based and module-based) are discovered and copied to `priv/ash_dispatch/templates/` with a manifest for lookup. No manual template compilation needed - it happens automatically! ## Resolution Strategy (Fallback Chain) Templates are resolved in priority order: 1. **Variant-specific:** `email.admin.html.heex` (if `variant: "admin"`) 2. **Transport-specific:** `email.html.heex` (default for transport) 3. **Generic fallback:** `default.html.heex` (if exists) 4. **Error:** `:template_not_found` ## Template Formats - **HEEx** (`.html.heex`) - HTML templates with HEEx attribute syntax - **EEx** (`.text.eex`) - Plain text templates with EEx ## Examples # Default email template TemplateResolver.render( event_dir: __DIR__, transport: :email, format: :html, assigns: %{user: user, order: order} ) # → Looks for: email.html.heex → default.html.heex # Admin-specific variant TemplateResolver.render( event_dir: __DIR__, variant: "admin", transport: :email, format: :html, assigns: assigns ) # → Looks for: email.admin.html.heex → email.html.heex → default.html.heex """ require Logger @doc """ Renders a template with the given options. ## Options * `:event_dir` - Optional in production. The directory of the event module (use `__DIR__`) * `:event_module` - Optional. The event module with compiled templates * `:event_id` - Optional. Event ID for convention-based path derivation * `:template_path` - Optional. Explicit template path override * `:otp_app` - Optional. OTP application name for convention-based paths * `:domain` - Optional. Domain name for convention-based paths (e.g., "requests") * `:resource_name` - Optional. Resource name for convention-based paths (e.g., "reseller_request") * `:format` - Required. Either `:html` or `:text` * `:transport` - Required. The transport type (e.g., `:email`) * `:variant` - Optional. A variant hint like "admin" or "user" * `:locale` - Optional. Locale for i18n template selection (e.g., "en", "sv") * `:assigns` - Required. Map of variables available in template as @variable ## Template Path Resolution Templates are resolved in this order: 1. **Module-based:** If `:event_module` with `__compiled_templates__/0`, use compiled templates 2. **Explicit path:** If `:event_dir` or `:template_path` provided, use that 3. **Convention-based:** Derive from `:event_id`, `:otp_app`, `:domain`, and `:resource_name` - Event ID: "reseller_request.new_reseller_request" - Domain: "requests" - Resource: "reseller_request" - Convention: "lib/{otp_app}/{domain}/templates/{resource_name}/{event_name}" - Result: "lib/magasin/requests/templates/reseller_request/new_reseller_request" - Legacy (no resource_name): "lib/magasin/requests/templates/new_reseller_request" 4. **Error:** `:template_not_found` ## Returns * `{:ok, rendered_string}` - Successfully rendered template * `{:error, :template_not_found}` - No matching template found * `{:error, reason}` - Rendering error """ def render(opts) do event_module = Keyword.get(opts, :event_module) event_dir = Keyword.get(opts, :event_dir) template_path = Keyword.get(opts, :template_path) event_id = Keyword.get(opts, :event_id) otp_app = Keyword.get(opts, :otp_app) # Domain name for template path resolution domain = Keyword.get(opts, :domain) # Resource name for template path resolution resource_name = Keyword.get(opts, :resource_name) format = Keyword.fetch!(opts, :format) transport = Keyword.fetch!(opts, :transport) variant = Keyword.get(opts, :variant) locale = Keyword.get(opts, :locale) assigns = Keyword.fetch!(opts, :assigns) # Try priv templates first (production), fall back to files (development) # Note: Explicit paths are prioritized over automatic lookups cond do # 1. Event module with compiled templates (legacy, deprecated) event_module && function_exported?(event_module, :__compiled_templates__, 0) -> render_from_compiled(event_module, transport, variant, locale, format, assigns) # 2. File-based: Explicit template path (highest priority for user overrides) template_path -> # Explicit path: already points to template directory, don't add /templates render_from_files( template_path, transport, variant, locale, format, assigns, false, otp_app ) # 3. File-based: Explicit event_dir with templates/ subdirectory event_dir -> # Module-based: event_dir is __DIR__, add /templates subdirectory render_from_files(event_dir, transport, variant, locale, format, assigns, true, otp_app) # 4. File-based: Derive event_dir from module source (development) event_module && derive_module_dir(event_module) -> render_from_files( derive_module_dir(event_module), transport, variant, locale, format, assigns, true, otp_app ) # 5. Priv directory manifest (module-based events) event_module && otp_app && priv_manifest_exists?(otp_app) -> render_from_priv_manifest( {:module, event_module}, otp_app, transport, variant, locale, format, assigns ) # 5. Priv directory manifest (convention-based events) event_id && otp_app && priv_manifest_exists?(otp_app) -> render_from_priv_manifest( {:event_id, event_id}, otp_app, transport, variant, locale, format, assigns ) # 6. File-based: Convention-based path (development fallback) event_id && otp_app -> # Convention-based: path already points to template directory, don't add /templates convention_path = derive_template_path(event_id, otp_app, domain, resource_name) render_from_files( convention_path, transport, variant, locale, format, assigns, false, otp_app ) true -> maybe_warn_missing_template_config(event_module, event_id, otp_app) {:error, :template_not_found} end end # Warn about missing compile_templates config when running in a release # This helps users understand why templates aren't loading in production defp maybe_warn_missing_template_config(event_module, event_id, otp_app) do # Only warn in release mode (when lib/ sources aren't available) if release_mode?() do identifier = cond do event_module -> "module: #{inspect(event_module)}" event_id -> "event_id: #{event_id}" true -> "unknown event" end has_manifest = case otp_app do nil -> false app -> priv_manifest_exists?(app) end unless has_manifest do Logger.warning(""" [AshDispatch] Template not found for #{identifier} This is likely because `compile_templates: true` is not set in config/prod.exs. In production releases, templates must be compiled to priv/ during build because lib/ source files are not included in releases. Add to config/prod.exs: config :ash_dispatch, compile_templates: true Then rebuild your release. See: https://hexdocs.pm/ash_dispatch/code-generation.html """) end end end # Detect if running in a release (Mix module is not available) defp release_mode? do not function_exported?(Mix, :env, 0) end # Check if priv manifest exists for the given OTP app defp priv_manifest_exists?(otp_app) do case manifest_path(otp_app) do {:ok, path} -> File.exists?(path) {:error, _} -> false end end # Derive directory from module's source file (for development mode) # Returns nil if source info is not available (e.g., in releases) defp derive_module_dir(event_module) do case event_module.__info__(:compile)[:source] do nil -> nil source -> source |> to_string() |> Path.dirname() end rescue _ -> nil end # Get manifest path for the given OTP app defp manifest_path(otp_app) do case :code.priv_dir(otp_app) do {:error, _} = error -> error priv_dir -> path = priv_dir |> to_string() |> Path.join("ash_dispatch/manifest.json") {:ok, path} end end # Render from compiled templates (production) defp render_from_compiled(event_module, transport, variant, locale, format, assigns) do templates = event_module.__compiled_templates__() candidates = build_template_candidates(transport, variant, locale, format) case Enum.find_value(candidates, fn nil -> nil filename -> Map.get(templates, filename) end) do nil -> {:error, :template_not_found} template_content -> render_template_content(template_content, assigns, format, locale: locale) end end # Render from priv directory manifest defp render_from_priv_manifest(lookup_key, otp_app, transport, variant, locale, format, assigns) do candidates = build_template_candidates(transport, variant, locale, format) with {:ok, manifest} <- load_manifest(otp_app), manifest_key <- format_manifest_key(lookup_key), template_map when not is_nil(template_map) <- Map.get(manifest, manifest_key), {:ok, dest_filename} <- find_template_in_map(template_map, candidates), {:ok, template_content} <- read_template_from_priv(otp_app, dest_filename) do render_template_content(template_content, assigns, format, otp_app: otp_app, transport: transport, locale: locale ) else _ -> {:error, :template_not_found} end end # Load and parse the manifest for the given OTP app defp load_manifest(otp_app) do with {:ok, path} <- manifest_path(otp_app), {:ok, content} <- File.read(path) do Jason.decode(content) else {:error, _} -> {:error, :manifest_not_found} end end # Format lookup key to manifest key format defp format_manifest_key({:event_id, event_id}), do: "event_id:#{event_id}" defp format_manifest_key({:module, module}), do: "module:#{inspect(module)}" # Find first matching template filename in template map defp find_template_in_map(template_map, candidates) do case Enum.find_value(candidates, fn nil -> nil filename -> Map.get(template_map, filename) end) do nil -> {:error, :template_not_found} dest_filename -> {:ok, dest_filename} end end # Read template content from priv directory of the given OTP app defp read_template_from_priv(otp_app, dest_filename) do case :code.priv_dir(otp_app) do {:error, _} -> {:error, :template_not_found} priv_dir -> priv_templates_dir = priv_dir |> to_string() |> Path.join("ash_dispatch/templates") template_path = Path.join(priv_templates_dir, dest_filename) case File.read(template_path) do {:ok, content} -> {:ok, content} {:error, _} -> {:error, :template_not_found} end end end # Render from files (development) defp render_from_files( event_dir, transport, variant, locale, format, assigns, add_templates_subdir, otp_app ) do template_path = resolve_template(event_dir, transport, variant, locale, format, add_templates_subdir) case template_path do {:ok, path} -> template_content = File.read!(path) render_template_content(template_content, assigns, format, otp_app: otp_app, transport: transport, locale: locale ) :error -> {:error, :template_not_found} end rescue error -> {:error, error} end defp resolve_template(event_dir, transport, variant, locale, format, add_templates_subdir) do # For module-based events, add /templates subdirectory # For convention-based paths, the path already points to the template directory templates_dir = if add_templates_subdir do Path.join(event_dir, "templates") else event_dir end candidates = build_template_candidates(transport, variant, locale, format) |> Enum.map(&Path.join(templates_dir, &1)) Enum.find_value(candidates, :error, fn path -> if File.exists?(path), do: {:ok, path}, else: nil end) end # Build template candidate filenames with locale support # Fallback chain: variant+locale → variant → locale → base → default+locale → default defp build_template_candidates(transport, variant, locale, format) do extension = extension_for(format) [ # Most specific: variant + locale variant && locale && "#{transport}.#{variant}.#{locale}.#{extension}", # Variant only (default locale) variant && "#{transport}.#{variant}.#{extension}", # Locale only locale && "#{transport}.#{locale}.#{extension}", # Base template "#{transport}.#{extension}", # Default with locale locale && "default.#{locale}.#{extension}", # Ultimate fallback "default.#{extension}" ] |> Enum.reject(&is_nil/1) |> Enum.uniq() end # Format to file extension mapping # Configure custom formats via: # config :ash_dispatch, format_extensions: %{markdown: "md.eex"} # # Default formats: # :html -> "html.heex" (HTML emails with HEEx syntax) # :text -> "text.eex" (Plain text emails/SMS) # # Future transports can add formats like: # :markdown -> "md.eex" (Discord/Slack messages) # :json -> "json.eex" (Webhook payloads) defp extension_for(format) do custom_extensions = Config.format_extensions() defaults = %{ html: "html.heex", text: "text.eex" } Map.get(custom_extensions, format) || Map.get(defaults, format) || raise "Unknown format #{inspect(format)}. Add it to config :ash_dispatch, format_extensions: %{#{format}: \"extension.eex\"}" end defp render_template_content(template_content, assigns, format, opts) do # Preprocess HEEx-style attribute syntax to EEx syntax. For :html # format the preprocessor wraps `{@var}` expansions in # `AshDispatch.SafeRender.escape/1` so user-controlled assigns can't # inject markup into the rendered output — matching Phoenix HEEx # auto-escape semantics. Other formats (text, etc.) are emitted as # plain `<%= @var %>` and rely on the format's own quoting rules. preprocessed = preprocess_heex_attributes(template_content, format) # Normalize assigns (convert struct to map if needed) normalized_assigns = if is_struct(assigns), do: Map.from_struct(assigns), else: assigns # Set Gettext locale before rendering so dgettext/t() calls in templates resolve correctly. # This enables single-template-per-event i18n: templates use Gettext instead of per-locale files. set_render_locale(Keyword.get(opts, :locale)) # Render with EEx rendered = EEx.eval_string(preprocessed, assigns: normalized_assigns) # Check if we should wrap in layout otp_app = Keyword.get(opts, :otp_app) transport = Keyword.get(opts, :transport) layout = Keyword.get(opts, :layout) if otp_app && transport do wrap_in_layout(rendered, otp_app, transport, format, normalized_assigns, layout) else {:ok, rendered} end rescue error -> {:error, error} end # Set Gettext locale for template rendering. # Enables dgettext()/t() calls in email templates to resolve per-recipient locale. # Falls back gracefully if no gettext_backend configured or locale is nil. defp set_render_locale(nil), do: :ok defp set_render_locale(locale) when is_binary(locale) do case AshDispatch.Config.gettext_backend() do nil -> :ok backend -> apply(Gettext, :put_locale, [backend, locale]) end rescue _ -> :ok end defp set_render_locale(locale) when is_atom(locale) and not is_nil(locale), do: set_render_locale(to_string(locale)) defp set_render_locale(_), do: :ok # Wrap rendered content in layout if layout exists # layout_subdir is an optional subdirectory (e.g., "urgent" -> layouts/urgent/email.html.heex) defp wrap_in_layout(content, otp_app, transport, format, assigns, layout_subdir) do case load_layout(otp_app, transport, format, layout_subdir) do {:ok, layout_content} -> # Add rendered content to assigns as @inner_content # Also ensure subject has a default to avoid template errors layout_assigns = assigns |> Map.put(:inner_content, content) |> Map.put_new(:subject, "") # Preprocess and render layout (locale already set by render_template_content) preprocessed = preprocess_heex_attributes(layout_content, format) rendered = EEx.eval_string(preprocessed, assigns: layout_assigns) {:ok, rendered} {:error, :layout_not_found} -> # No layout, return content as-is {:ok, content} end rescue error -> {:error, error} end # Load layout from priv/ash_dispatch/layouts/ # layout_subdir allows per-channel overrides (e.g., "urgent" -> layouts/urgent/email.html.heex) defp load_layout(otp_app, transport, format, layout_subdir) defp load_layout(otp_app, transport, format, layout_subdir) do extension = extension_for(format) layout_filename = "#{transport}.#{extension}" case :code.priv_dir(otp_app) do {:error, _} -> {:error, :layout_not_found} priv_dir -> base_path = priv_dir |> to_string() |> Path.join("ash_dispatch/layouts") # If layout_subdir specified, try that first, then fall back to default layout_paths = if layout_subdir do [ Path.join([base_path, layout_subdir, layout_filename]), Path.join(base_path, layout_filename) ] else [Path.join(base_path, layout_filename)] end # Try each path in order Enum.find_value(layout_paths, {:error, :layout_not_found}, fn path -> case File.read(path) do {:ok, content} -> {:ok, content} {:error, _} -> nil end end) end end # Preprocessor to convert HEEx-style interpolation to EEx syntax. # For `format == :html`, `{@var}` expansions are wrapped in # `AshDispatch.SafeRender.escape/1` so they auto-escape (matching # Phoenix HEEx). Non-HTML formats emit `<%= … %>` unchanged. # # Handles: # 1. attr={@variable} → attr="<%= AshDispatch.SafeRender.escape(@variable) %>" (html) # → attr="<%= @variable %>" (text) # 2. attr={"string #{@var}"} → attr="<%= "string #{@var}" %>" (unchanged — full Elixir) # 3. {@variable} → <%= AshDispatch.SafeRender.escape(@variable) %> (html) # → <%= @variable %> (text) defp preprocess_heex_attributes(template, format) do template |> convert_attribute_syntax() |> convert_standalone_patterns(format) end # Elixir keywords/macros that should be converted when used in HEEx {} syntax @elixir_keywords ~w(if unless case cond for with) # Convert standalone {@ and #{@ patterns, but NOT inside <%= ... %> tags defp convert_standalone_patterns(template, format) do parts = Regex.split(~r/<%=|%>/, template, include_captures: true) parts |> Enum.with_index() |> Enum.map(fn {part, idx} -> cond do # Keep <%= and %> delimiters as-is part in ["<%= ", " %>", "<%=", "%>"] -> part # If previous part was <%=, we're inside an EEx tag - don't convert idx > 0 and Enum.at(parts, idx - 1) in ["<%= ", "<%="] -> part # Otherwise, convert {@ and #{@ patterns true -> convert_heex_expressions(part, format) end end) |> Enum.join("") end # Convert HEEx curly-brace expressions to EEx <%= %> syntax # Handles nested braces correctly by using a stateful parser defp convert_heex_expressions(text, format) do do_convert_heex_expressions(text, "", format) end defp do_convert_heex_expressions("", acc, _format), do: acc defp do_convert_heex_expressions(<>, acc, format) do # String interpolation - skip it do_convert_heex_expressions(rest, acc <> "\#{", format) end defp do_convert_heex_expressions(<>, acc, format) do # Found opening brace - try to extract complete expression case extract_balanced_expression(rest, 0, "") do {:ok, expr, remaining} -> if should_convert_expression?(expr) do rendered = render_expression(expr, format) do_convert_heex_expressions(remaining, acc <> rendered, format) else do_convert_heex_expressions(remaining, acc <> "{" <> expr <> "}", format) end :error -> # No matching brace, keep as-is do_convert_heex_expressions(rest, acc <> "{", format) end end defp do_convert_heex_expressions(<>, acc, format) do do_convert_heex_expressions(rest, acc <> <>, format) end # Wrap the expansion in SafeRender.escape/1 for HTML formats so plain # `{@var}` auto-escapes. For non-HTML formats emit plain `<%= … %>`. # Already wrapped in an explicit `AshDispatch.SafeRender.raw(…)` call? # Strip the marker and skip the escape — matches Phoenix HEEx's # `Phoenix.HTML.raw/1` opt-out for trusted markup. defp render_expression(expr, :html) do trimmed = String.trim(expr) case strip_raw_call(trimmed) do {:ok, inner} -> "<%= " <> inner <> " %>" :no -> "<%= AshDispatch.SafeRender.escape(" <> trimmed <> ") %>" end end defp render_expression(expr, _format), do: "<%= " <> expr <> " %>" # Match `AshDispatch.SafeRender.raw(EXPR)` or `raw(EXPR)` exactly. defp strip_raw_call(expr) do cond do String.starts_with?(expr, "AshDispatch.SafeRender.raw(") and String.ends_with?(expr, ")") -> {:ok, String.slice(expr, String.length("AshDispatch.SafeRender.raw(") .. -2//1)} String.starts_with?(expr, "raw(") and String.ends_with?(expr, ")") -> {:ok, String.slice(expr, String.length("raw(") .. -2//1)} true -> :no end end # Extract a balanced expression handling nested braces and strings defp extract_balanced_expression("", _depth, _acc), do: :error defp extract_balanced_expression(<>, 0, acc) do {:ok, acc, rest} end defp extract_balanced_expression(<>, depth, acc) when depth > 0 do extract_balanced_expression(rest, depth - 1, acc <> "}") end defp extract_balanced_expression(<>, depth, acc) do extract_balanced_expression(rest, depth + 1, acc <> "{") end defp extract_balanced_expression(<>, depth, acc) do # Inside string - extract until closing quote (handling escapes) case extract_string(rest, <>) do {:ok, str_content, remaining} -> extract_balanced_expression(remaining, depth, acc <> str_content) :error -> :error end end defp extract_balanced_expression(<>, depth, acc) do extract_balanced_expression(rest, depth, acc <> <>) end # Extract a string literal handling escape sequences defp extract_string("", _acc), do: :error defp extract_string(<<"\\\""::utf8, rest::binary>>, acc) do extract_string(rest, acc <> "\\\"") end defp extract_string(<>, acc) do {:ok, acc <> "\"", rest} end defp extract_string(<>, acc) do extract_string(rest, acc <> <>) end # Determine if an expression should be converted to <%= %> syntax defp should_convert_expression?(expr) do trimmed = String.trim(expr) cond do # @variable expressions String.starts_with?(trimmed, "@") -> true # Elixir keywords/macros (if, unless, case, cond, for, with) starts_with_elixir_keyword?(trimmed) -> true # Function calls like Module.function() or function() String.match?(trimmed, ~r/^[a-zA-Z_][a-zA-Z0-9_]*[.\(]/) -> true # assigns[:key] pattern String.starts_with?(trimmed, "assigns[") -> true # Single lowercase identifier without parens - likely a CSS class or similar String.match?(trimmed, ~r/^[a-z_][a-z0-9_]*$/) -> false true -> false end end defp starts_with_elixir_keyword?(expr) do Enum.any?(@elixir_keywords, fn keyword -> String.starts_with?(expr, keyword <> " ") or String.starts_with?(expr, keyword <> "(") end) end # Convert HEEx attribute syntax to EEx defp convert_attribute_syntax(template) do do_convert_attributes(template, "", :normal) end defp do_convert_attributes("", acc, _state), do: acc defp do_convert_attributes(template, acc, :normal) do case Regex.run(~r/(? <> = template <<_skip::binary-size(^attr_start), attr_name::binary-size(^attr_len), _::binary>> = template case find_matching_brace(rest) do {:ok, content, consumed_len} -> converted = ~s(#{attr_name}="<%= #{content} %>") <<_content_and_brace::binary-size(^consumed_len + 1), remaining::binary>> = rest do_convert_attributes(remaining, acc <> before <> converted, :normal) :error -> kept_len = match_start + match_len <> = template do_convert_attributes(new_rest, acc <> kept, :normal) end nil -> acc <> template end end defp find_matching_brace(string), do: find_matching_brace(string, 0, 0, "", false) defp find_matching_brace("", _depth, _pos, _acc, _in_string), do: :error defp find_matching_brace(<>, depth, pos, acc, in_string) do find_matching_brace(rest, depth, pos + 1, acc <> <>, not in_string) end defp find_matching_brace(<>, depth, pos, acc, false = in_string) do find_matching_brace(rest, depth + 1, pos + 1, acc <> <>, in_string) end defp find_matching_brace(<>, 0, pos, acc, false = _in_string) do {:ok, acc, pos} end defp find_matching_brace(<>, depth, pos, acc, false = in_string) when depth > 0 do find_matching_brace(rest, depth - 1, pos + 1, acc <> <>, in_string) end defp find_matching_brace(<>, depth, pos, acc, in_string) do find_matching_brace(rest, depth, pos + 1, acc <> <>, in_string) end @doc """ Resolves the template directory for an event. This is the **single source of truth** for template path resolution. Used by both the code generator and the runtime renderer. ## Resolution Order 1. **Explicit module**: If event has `module:` option, use module-based path 2. **Derived module**: Use `{App}.{Domain}.Events.{EventName}.Event` path Events are expected to have modules (either explicit or generated via `mix ash_dispatch.gen`). Templates live in `{module_dir}/templates/`. ## Parameters - `event_info` - Map with `:domain`, `:name`, and optionally `:module` - `otp_app` - The OTP application name ## Examples resolve_template_directory(%{domain: :accounts, name: :email_confirmation}, :magasin) # => "lib/magasin/accounts/events/email_confirmation/templates" resolve_template_directory(%{domain: :orders, name: :created}, :magasin) # => "lib/magasin/orders/events/created/templates" """ @spec resolve_template_directory(map(), atom()) :: String.t() def resolve_template_directory(event_info, otp_app) do # 1. Explicit module takes priority case Map.get(event_info, :module) do module when is_atom(module) and not is_nil(module) -> module_path_from_module(module) nil -> # 2. Use derived module path # Events should have modules - either explicit or generated via codegen derived_module = derive_event_module(event_info, otp_app) module_path_from_module(derived_module) end end @doc """ Derives the expected event module name from event info. Delegates to `AshDispatch.Naming.event_module/3` which is the single source of truth for module name derivation. ## Examples derive_event_module(%{domain: :accounts, name: :email_confirmation, resource: Magasin.Accounts.User}, :magasin) # => Magasin.Accounts.Events.EmailConfirmation.Event """ @spec derive_event_module(map(), atom()) :: module() def derive_event_module(event_info, otp_app) do # If resource is available, use Naming.event_module for consistency # Otherwise fall back to otp_app-based derivation for backward compatibility case event_info[:resource] do resource when is_atom(resource) and not is_nil(resource) -> domain = event_info[:domain] || Naming.domain_name(resource) Naming.event_module(resource, domain, event_info[:name]) nil -> # Fallback: derive from otp_app (for legacy/test cases) app_module = otp_app |> to_string() |> Macro.camelize() domain_module = event_info[:domain] |> to_string() |> Macro.camelize() event_module = event_info[:name] |> to_string() |> Macro.camelize() Module.concat([app_module, domain_module, "Events", event_module, "Event"]) end end # Derives template path from module name # Delegates to Naming.template_directory/1 for consistent path derivation # Module: Magasin.Accounts.Events.PasswordReset.Event # Path: lib/magasin/accounts/events/password_reset/templates defp module_path_from_module(module) do Naming.template_directory(module) end @doc """ Derives template path from event_id. Parses the event_id to extract domain and event name, then uses `resolve_template_directory/2` for the actual path resolution. ## Parameters - `event_id` - Event ID string (e.g., "orders.created", "user.email_confirmation") - `otp_app` - The OTP application name - `domain` - Optional domain override (uses first part of event_id if not provided) ## Examples derive_template_path("orders.created", :magasin) # => "lib/magasin/orders/events/created/templates" derive_template_path("user.email_confirmation", :magasin, "accounts") # => "lib/magasin/accounts/events/email_confirmation/templates" """ def derive_template_path(event_id, otp_app, domain \\ nil, _resource_name \\ nil) when is_binary(event_id) and is_atom(otp_app) do case String.split(event_id, ".", parts: 2) do [resource_or_domain, event_name] -> domain_atom = if domain, do: String.to_atom(to_string(domain)), else: String.to_atom(resource_or_domain) resolve_template_directory( %{domain: domain_atom, name: String.to_atom(event_name)}, otp_app ) [single_part] -> domain_atom = if domain, do: String.to_atom(to_string(domain)), else: :dispatch resolve_template_directory( %{domain: domain_atom, name: String.to_atom(single_part)}, otp_app ) end end end