defmodule Mix.Tasks.AshDispatch.Gen.Previews do @shortdoc "Generates static HTML previews of all email templates" @moduledoc """ Generates static HTML/text preview files for all AshDispatch events. This task renders each event template with its `sample_data/0` and outputs static files for easy preview, documentation, or CI validation. ## Usage mix ash_dispatch.gen.previews # Generate all previews mix ash_dispatch.gen.previews --output priv/previews # Custom output dir mix ash_dispatch.gen.previews --check # CI mode - fail if outdated mix ash_dispatch.gen.previews --verbose # Show detailed output ## Output Structure Previews are generated to `priv/ash_dispatch/previews/` by default: priv/ash_dispatch/previews/ ├── index.html # Index with links to all previews ├── user.password_reset/ │ ├── email.user.html # Rendered HTML email for user audience │ ├── email.user.txt # Rendered text email for user audience │ └── metadata.json # Event metadata (subject, from, etc.) ├── user.email_confirmation/ │ ├── email.user.html │ ├── email.user.txt │ └── metadata.json └── orders.created/ ├── email.user.html # User audience template ├── email.admin.html # Admin audience template ├── email.admin.summary.html # Admin with 'summary' variant ├── email.user.txt ├── email.admin.txt ├── email.admin.summary.txt └── metadata.json ## Integration with Ash Codegen This task can be run alongside other generators: mix ash.codegen # Runs all extension codegens mix ash_dispatch.gen.previews # Run after to generate previews ## Use Cases - **Design review**: Share rendered emails with designers - **Documentation**: Include in project docs - **CI validation**: Ensure templates render without errors - **Quick testing**: View all emails without running the app """ use Mix.Task @requirements ["app.config"] @impl Mix.Task def run(args) do # Guard: prevent running from ash_dispatch library itself if Mix.Project.config()[:app] == :ash_dispatch do Mix.shell().error("This task cannot be run from the ash_dispatch library itself.") Mix.shell().info("Run this task from your consuming application instead.") exit({:shutdown, 1}) end Mix.Task.run("compile") # Start the application to ensure all dependencies (Faker, Smokestack, etc.) are ready Mix.Task.run("app.start") {opts, _, _} = OptionParser.parse(args, switches: [ output: :string, check: :boolean, verbose: :boolean ], aliases: [o: :output, v: :verbose] ) otp_app = Mix.Project.config()[:app] output_dir = opts[:output] || default_output_dir(otp_app) if opts[:verbose] do Mix.shell().info("Generating previews to: #{output_dir}") end # Discover all events with modules that have sample_data events = discover_previewable_events(otp_app) if opts[:verbose] do Mix.shell().info("Found #{length(events)} previewable events") end if Enum.empty?(events) do Mix.shell().info("No events with sample_data/0 found. Nothing to generate.") {:ok, []} else # Generate previews results = generate_all_previews(events, output_dir, otp_app, opts) # Generate index generate_index(events, results, output_dir, otp_app) # Handle --check flag if opts[:check] do check_previews_up_to_date(results, output_dir) end total_files = count_files(results) Mix.shell().info("\nGenerated #{total_files} preview file(s) to #{output_dir}") {:ok, results} end end # ============================================================================ # Event Discovery # ============================================================================ defp discover_previewable_events(otp_app) do alias AshDispatch.EventResolver # Get all events from introspection events = AshDispatch.Introspection.all_events(otp_app) # Merge: DSL events take precedence, but fill in modules from registry events_with_modules = Enum.map(events, fn event -> if event.module do event else # Try to find module in registry using EventResolver case EventResolver.find_module(event.event_id) do {:ok, module} -> Map.put(event, :module, module) {:error, :not_found} -> event end end end) # Filter to events that have modules with sample_data events_with_modules |> Enum.filter(fn event -> module = event.module # Use EventResolver.exports? for safe check module != nil && EventResolver.exports?(module, :sample_data, 0) end) |> Enum.sort_by(& &1.event_id) end # ============================================================================ # Preview Generation # ============================================================================ defp generate_all_previews(events, output_dir, otp_app, opts) do Enum.map(events, fn event -> generate_event_preview(event, output_dir, otp_app, opts) end) end defp generate_event_preview(event, output_dir, otp_app, opts) do module = event.module event_id = event.event_id event_dir = Path.join(output_dir, safe_filename(event_id)) # Ensure directory exists File.mkdir_p!(event_dir) # Get sample data from module sample_data = module.sample_data() # Build context with sample data context = build_preview_context(event, sample_data, otp_app) # Get channels - DSL channels take precedence, fall back to module callback channels = get_preview_channels(event, module, context) # Generate previews for each email channel files = channels |> Enum.filter(&(&1.transport == :email)) |> Enum.flat_map(fn channel -> generate_channel_preview(event, module, context, channel, event_dir, otp_app, opts) end) # Generate metadata.json metadata_file = generate_metadata(event, module, context, channels, event_dir) if opts[:verbose] do Mix.shell().info(" Generated #{length(files) + 1} files for #{event_id}") end %{ event_id: event_id, event_dir: event_dir, files: files ++ [metadata_file], success: true } rescue error -> Mix.shell().error("Failed to generate preview for #{event.event_id}: #{inspect(error)}") %{ event_id: event.event_id, event_dir: Path.join(output_dir, safe_filename(event.event_id)), files: [], success: false, error: error } end defp generate_channel_preview(event, module, context, channel, event_dir, otp_app, opts) do alias AshDispatch.EventResolver audience = channel.audience # Use EventResolver for safe callback execution with defaults variant = channel.variant || EventResolver.template_variant(module, context, channel) # Get subject and from for this channel using EventResolver subject = EventResolver.subject(module, context, channel) || "Preview Subject" from_result = EventResolver.from(module, context, channel) {from_name, from_email} = from_result || {"Preview", "preview@example.com"} # Prepare template assigns using EventResolver base_assigns = EventResolver.prepare_template_assigns(module, context, channel) assigns = context.data |> Map.merge(base_assigns) |> Map.put(:subject, subject) files = [] # Generate HTML preview html_result = AshDispatch.TemplateResolver.render( event_module: module, format: :html, transport: :email, variant: variant, assigns: assigns, otp_app: otp_app ) files = case html_result do {:ok, html_content} -> # Wrap in preview shell with metadata (now includes audience) wrapped_html = wrap_html_preview(html_content, subject, from_name, from_email, event, audience) filename = AshDispatch.Naming.filename("email", audience, variant, "html") path = Path.join(event_dir, filename) File.write!(path, wrapped_html) if opts[:verbose] do Mix.shell().info([:green, " * creating ", :reset, path]) end files ++ [ %{ path: path, format: :html, variant: variant, audience: audience, transport: channel.transport } ] {:error, reason} -> if opts[:verbose] do Mix.shell().info([:yellow, " * skipped HTML: ", :reset, inspect(reason)]) end files end # Generate text preview text_result = AshDispatch.TemplateResolver.render( event_module: module, format: :text, transport: :email, variant: variant, assigns: assigns, otp_app: otp_app ) files = case text_result do {:ok, text_content} -> filename = AshDispatch.Naming.filename("email", audience, variant, "txt") path = Path.join(event_dir, filename) File.write!(path, text_content) if opts[:verbose] do Mix.shell().info([:green, " * creating ", :reset, path]) end files ++ [ %{ path: path, format: :text, variant: variant, audience: audience, transport: channel.transport } ] {:error, reason} -> if opts[:verbose] do Mix.shell().info([:yellow, " * skipped text: ", :reset, inspect(reason)]) end files end files end defp generate_metadata(event, module, context, channels, event_dir) do email_channels = Enum.filter(channels, &(&1.transport == :email)) # Get metadata for first email channel (or build default) channel = List.first(email_channels) || %AshDispatch.Channel{transport: :email, audience: :user} subject = module.subject(context, channel) {from_name, from_email} = module.from(context, channel) metadata = %{ event_id: event.event_id, domain: event.domain, resource: event.resource && inspect(event.resource), module: inspect(module), subject: subject, from: %{name: from_name, email: from_email}, channels: Enum.map(channels, fn ch -> %{ transport: ch.transport, audience: ch.audience, variant: ch.variant } end), generated_at: DateTime.utc_now() |> DateTime.to_iso8601() } path = Path.join(event_dir, "metadata.json") File.write!(path, Jason.encode!(metadata, pretty: true)) %{path: path, format: :json, variant: nil} end # ============================================================================ # Index Generation # ============================================================================ defp generate_index(events, results, output_dir, otp_app) do successful_results = Enum.filter(results, & &1.success) app_name = otp_app |> to_string() |> Macro.camelize() html = """ #{app_name} - Email Template Previews

📧 #{app_name} Email Templates

#{length(successful_results)} events #{count_files(results)} files generated
#{Enum.map_join(successful_results, "\n", &render_event_card(&1, events))}

Generated by mix ash_dispatch.gen.previews at #{DateTime.utc_now() |> DateTime.to_iso8601()}

""" path = Path.join(output_dir, "index.html") File.write!(path, html) Mix.shell().info([:green, "* creating ", :reset, path]) end defp render_event_card(result, events) do event = Enum.find(events, &(&1.event_id == result.event_id)) event_dir = safe_filename(result.event_id) # Read metadata if available metadata_path = Path.join(result.event_dir, "metadata.json") subject = if File.exists?(metadata_path) do case Jason.decode(File.read!(metadata_path)) do {:ok, meta} -> meta["subject"] || "No subject" _ -> "No subject" end else "No subject" end # Group template files by audience+variant, then show HTML/text together template_files = Enum.filter(result.files, &(&1.format in [:html, :text])) grouped = template_files |> Enum.group_by(fn file -> {file[:transport], file[:audience], file[:variant]} end) channel_links = grouped |> Enum.sort_by(fn {{_transport, audience, _variant}, _files} -> # Sort user first, then admin, then others case audience do :user -> 0 :admin -> 1 _ -> 2 end end) |> Enum.map(fn {{transport, audience, variant}, files} -> html_file = Enum.find(files, &(&1.format == :html)) text_file = Enum.find(files, &(&1.format == :text)) label = AshDispatch.Naming.label(transport, audience, variant) # Use variant styling if has meaningful variant or is admin audience class = if AshDispatch.Naming.include_variant?(audience, variant) || audience == :admin do "channel-group variant" else "channel-group" end html_link = if html_file do filename = Path.basename(html_file.path) ~s(#{label}) else "" end text_link = if text_file do filename = Path.basename(text_file.path) ~s(text) else "" end ~s(#{html_link}#{text_link}) end) |> Enum.join("") """
#{event.domain || "unknown"}

#{result.event_id}

#{escape_html(subject)}
""" end # ============================================================================ # Check Mode # ============================================================================ defp check_previews_up_to_date(results, output_dir) do failed = Enum.filter(results, &(!&1.success)) if length(failed) > 0 do Mix.shell().error("\nPreview generation failed for #{length(failed)} event(s):") Enum.each(failed, fn result -> Mix.shell().error(" - #{result.event_id}: #{inspect(result.error)}") end) exit({:shutdown, 1}) end # Check if index exists index_path = Path.join(output_dir, "index.html") unless File.exists?(index_path) do Mix.shell().error("\nIndex file missing: #{index_path}") exit({:shutdown, 1}) end :ok end # ============================================================================ # Helpers # ============================================================================ defp default_output_dir(_otp_app) do # Output to project's priv directory (not _build) Path.join(["priv", "ash_dispatch", "previews"]) end defp build_preview_context(event, sample_data, _otp_app) do %AshDispatch.Context{ event_id: event.event_id, data: sample_data, variables: %{}, metadata: %{}, resource_key: event[:data_key] || :record } end defp get_preview_channels(event, module, context) do # Use centralized ChannelResolver for consistent priority logic AshDispatch.ChannelResolver.resolve( event.event_id, module, context, dsl_channels: event[:channels] ) end defp safe_filename(event_id) do String.replace(event_id, ~r/[^a-zA-Z0-9_.-]/, "_") end defp count_files(results) do results |> Enum.filter(& &1.success) |> Enum.map(&length(&1.files)) |> Enum.sum() end defp wrap_html_preview(content, subject, from_name, from_email, event, audience) do audience_badge = if audience do ~s(#{audience}) else "" end """ #{escape_html(subject)}
Event: #{event.event_id}
Audience: #{audience_badge}
Subject: #{escape_html(subject)}
From: #{escape_html(from_name)} <#{escape_html(from_email)}>
← Back to all templates
#{content}
""" end defp escape_html(nil), do: "" defp escape_html(string) when is_binary(string) do string |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace("\"", """) end defp escape_html(other), do: escape_html(to_string(other)) end