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 = """
Generated by mix ash_dispatch.gen.previews at #{DateTime.utc_now() |> DateTime.to_iso8601()}