MishkaCmsCore.Runtime.Compilers.LiveViewCompiler (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Runtime compiler for Phoenix LiveView modules with dynamic content compilation.

This module provides comprehensive functionality to dynamically compile Phoenix LiveView modules at runtime from configuration data. It generates complete LiveView modules with proper template compilation, event handling, lifecycle management, and metadata for CMS pages.

Features

  • Dynamic LiveView module generation from configuration parameters
  • HEEx template compilation with Phoenix LiveView integration
  • Event handler compilation for interactive functionality (handle_event/3)
  • Info handler compilation for message processing (handle_info/2)
  • Page metadata and information functions for runtime introspection
  • Helper function integration for custom page logic
  • SEO and schema metadata with customizable tags
  • Multi-site page support with site-specific configurations
  • Template rendering utilities for runtime template processing

LiveView Module Structure

Generated LiveView modules include:

  • __page_information__/0 - Returns complete page metadata
  • __page_information__/1 - Returns specific metadata by key or path
  • render/1 - Main template rendering function
  • templates/1 - Template collection function
  • handle_event/3 - Event handlers (if defined)
  • handle_info/2 - Message handlers (if defined)
  • Helper functions (if defined in configuration)
  • Proper Phoenix LiveView and MishkaCMS integration

Basic Usage

The primary entry point is build_module/1 which takes page configuration and returns a complete AST ready for compilation:

params = %{
  title: "Home Page",
  page_module: MyApp.Pages.Home,
  template: ~S'''
  <div class="home-page">
    <h1><%= @title %></h1>
    <p>Welcome to our site!</p>

    <button phx-click="increment">
      Count: <%= @count %>
    </button>

    <%= for item <- @items do %>
      <div class="item"><%= item.name %></div>
    <% end %>
  </div>
  ''',
  events: [
    %{
      name: "increment",
      code: ~S'''
      count = (socket.assigns[:count] || 0) + 1
      {:noreply, assign(socket, :count, count)}
      '''
    }
  ],
  site: "main_site",
  site_id: 1
}

ast = LiveViewCompiler.build_module(params)
MishkaCmsCore.Runtime.Compilers.ModuleCompiler.compile_module(ast)

Page Information Access

Once compiled, pages provide comprehensive metadata access:

# Get complete page information
info = MyApp.Pages.Home.__page_information__()

# Access specific sections
page_data = MyApp.Pages.Home.__page_information__(:page)
permissions = MyApp.Pages.Home.__page_information__(:acl)

# Access nested data with dot notation
site_id = MyApp.Pages.Home.__page_information__("private.site_id")
schema = MyApp.Pages.Home.__page_information__("page.seo.schema")

SEO and Schema Metadata

Pages support comprehensive SEO and structured data:

seo_tags: [
  %{name: "description", content: "Page description"},
  %{name: "keywords", content: "keyword1, keyword2"},
  %{property: "og:title", content: "Social Media Title"},
  %{property: "og:image", content: "https://example.com/image.png"}
]

schema: [
  %{
    "@context" => "https://schema.org",
    "@type" => "Article",
    "headline" => "Article Title",
    "author" => %{"@type" => "Person", "name" => "John Doe"},
    "datePublished" => "2024-01-15"
  }
]

Helper Functions in Pages

Helper functions provide custom logic within pages:

helpers: [
  %{
    name: "calculate_discount",
    args: "price, discount_percent",
    code: ~S'''
    discount_amount = price * (discount_percent / 100)
    price - discount_amount
    '''
  },
  %{
    name: "render_status_badge",
    args: "status",
    code: ~S'''
    class = case status do
      "active" -> "badge-success"
      "pending" -> "badge-warning"
      "inactive" -> "badge-danger"
      _ -> "badge-secondary"
    end

    ~H"""
    <span class={"badge " <> class}>
      <%= String.capitalize(status) %>
    </span>
    """
    '''
  }
]

Error Handling and Safety

The compiler includes comprehensive error handling:

  • Template compilation errors are caught and reported with context
  • Event handler compilation validates syntax before module creation
  • Catch-all handlers prevent crashes from unhandled events/messages
  • Safe code evaluation with proper error boundaries
  • Runtime template rendering with graceful error handling

Performance Considerations

  • Template compilation is performed once during module compilation
  • Event handlers are compiled as efficient pattern-matched functions
  • Helper functions are compiled as regular module functions
  • Metadata access uses compiled functions for optimal performance
  • Template rendering utilities use optimized Phoenix engines

Multi-Site Page Management

Pages support multi-site configurations:

# Site-specific page configuration
params = %{
  title: "Site A Home",
  page_module: MyApp.Pages.SiteA.Home,
  template: site_a_template,
  site: "site_a",
  site_id: 1,
  host: "sitea.example.com"
}

Summary

Functions

Builds page information and metadata functions.

Builds event handler functions for LiveView interactivity.

Builds helper functions for custom page logic.

Builds info handler functions for LiveView message processing.

Builds a complete LiveView module AST from configuration parameters.

Compiles the page's own forms into __page_forms__/0, and generates the change and submit handlers for every form whose event names the author has NOT declared.

Gives a page the handle_event/3 clauses for whatever server actions it uses.

Compiles the page's state into the assigns it starts with — see MishkaCmsCore.Runtime.PageState, which the mounting LiveView calls.

Builds render functions for LiveView template rendering.

Compiles the page's upload declarations into __page_uploads__/0, for the LiveView that mounts it to register.

The template a page really compiles: its own markup, with the confirmation region in front of it when the page declares a form.

Creates the final LiveView module AST with all components assembled.

The default code for a form's event on the Events tab: print the params, then run form_handler_source/2, so the form keeps working.

The body of a form's generated handle_event/3 clause, as source: kind is :validate or :submit, and event is the event name the clause matches, which is also how the body finds its form in __page_forms__/0.

The quoted argument list of a helper whose args is args, for def name(unquote_splicing(…)): "" or nil is no argument, "assigns" one, "assigns, suffix" two, defaults included.

Creates compilation environment for template and code processing.

Renders template with the classic Phoenix.HTML.Engine (not HEEx), matching how the runtime serves error-page templates (MishkaCmsCore.Runtime.Compilers.ErrorCompiler compiles them with the same engine). Unlike render_template/2, this engine does not tokenize HTML structurally, so it allows EEx interpolation inside attribute values (content="<%= ... %>") — which a server-rendered root layout relies on and which HEEx rejects.

Renders isolated HEEx template with assigns for runtime operations not page compiling.

Renders a HEEx template with assigns, raises on error.

Whether a layout already shows messages — what confirmable/3 takes as its third argument.

Whether code is form_handler_source/2 or form_handler_sample/2 for event as written, whitespace aside. A quoted name carrying the same token and kind as event counts as event, so an untouched body still reads as untouched after its form is renamed.

Functions

build_assigns(params)

Builds page information and metadata functions.

Creates AST for the __page_information__ functions that provide comprehensive access to page metadata, private information, ACL permissions, and extra data. These functions are essential for runtime introspection and page management.

Parameters

  • params - Atomized page configuration map containing all page data

Returns

Returns a tuple {params, functions_ast} where:

  • params - Original parameters (passed through for pipeline)
  • functions_ast - List containing the page information function ASTs

Generated Functions

The generated module will contain these information functions:

__page_information__/0

Returns complete page metadata as a structured map with sections:

  • :page - Core page data (title, path, language, content, SEO, schema)
  • :private - Private metadata (IDs, module info, site data, whether the page reads the signed-in user — see MishkaCmsCore.Runtime.Compilers.UserClosure)
  • :acl - Access control permissions
  • :extra - Additional custom metadata

__page_information__/1 (atom key)

Returns specific top-level section by atom key.

__page_information__/1 (list of keys)

Returns nested data using a list of keys for deep access.

__page_information__/1 (dot-notation string)

Returns nested data using dot-notation string paths like "private.site_id".

Page Information Structure

%{
  page: %{
    title: "Page Title",
    path: "/pages/example",
    section: "blog",
    language: "en",
    language_group_id: "0192f1d5-…",
    timestamp: ~N[2024-01-15 10:30:00],
    content: "page content",
    seo: %{
      description: "Shown by search engines and link previews",
      meta_tags: [%{name: "description", content: "..."}],
      schema: [%{"@type" => "Article", "headline" => "..."}]
    }
  },
  private: %{
    id: 123,
    page_module: MyApp.Pages.Example,
    priority: 0,
    site: "main_site",
    site_id: 1,
    host: "example.com",
    format: :heex,
    reads_user: false
  },
  acl: %{permissions: ["page_view", "page_edit"]},
  extra: %{analytics_id: "page_123", cache_ttl: 3600}
}

Examples

# After compilation, access page information:
info = MyPage.__page_information__()
page_data = MyPage.__page_information__(:page)
site_id = MyPage.__page_information__("private.site_id")
seo_tags = MyPage.__page_information__([:page, :seo, :meta_tags])

Error Handling

The string-based accessor includes error handling for invalid paths, returning nil when the path cannot be resolved or atoms don't exist.

This function is typically called as the first stage in the compilation pipeline.

build_event_handlers(arg)

Builds event handler functions for LiveView interactivity.

Compiles event handler definitions into handle_event/3 functions that respond to user interactions like clicks, form submissions, and keyboard events.

Parameters

  • {params, functions_ast} - Tuple containing:
    • params - Page configuration including :events list
    • functions_ast - Existing function ASTs from previous pipeline stages

Returns

Returns updated tuple {params, functions_ast} with event handler functions added.

Event Handler Format

Each event handler should be defined as a map with:

  • :name - Event name as string (required)
  • :code - Handler code as Elixir code string (required)

Examples

events: [
  %{
    name: "increment",
    code: ~S'''
    current_count = socket.assigns[:count] || 0
    {:noreply, assign(socket, :count, current_count + 1)}
    '''
  },
  %{
    name: "save_form",
    code: ~S'''
    case MyApp.Forms.save(params) do
      {:ok, result} ->
        {:noreply,
         socket
         |> put_flash(:info, "Saved successfully!")
         |> assign(:data, result)}

      {:error, changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}
    end
    '''
  },
  %{
    name: "delete_item",
    code: ~S'''
    item_id = String.to_integer(params["id"])

    case MyApp.Items.delete(item_id) do
      :ok ->
        items = Enum.reject(socket.assigns.items, &(&1.id == item_id))
        {:noreply, assign(socket, :items, items)}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "Failed to delete item")}
    end
    '''
  }
]

Generated Functions

Each event definition creates a handle_event/3 function:

def handle_event("increment", _params, socket) do
  current_count = socket.assigns[:count] || 0
  {:noreply, assign(socket, :count, current_count + 1)}
end

Template Usage

Event handlers are triggered from templates using Phoenix LiveView bindings:

<button phx-click="increment">+1</button>

<form id="save-form" phx-submit="save_form">
  <input type="text" name="title" />
  <button type="submit">Save</button>
</form>

<button phx-click="delete_item" phx-value-id={item.id}>
  Delete
</button>

Available Variables

Event handler code has access to:

  • params - Event parameters from the client
  • socket - The LiveView socket with assigns and state
  • All imported functions from Phoenix.LiveView
  • Helper functions defined in the same module

Return Values

Event handlers must return valid LiveView responses:

  • {:noreply, socket} - Update socket and re-render
  • {:reply, map, socket} - Send reply to client and update socket
  • {:noreply, socket, timeout} - Update with timeout

Catch-All Handler

A catch-all handle_event/3 is automatically added that:

  • Handles any unmatched events gracefully
  • Returns {:noreply, socket} to prevent crashes
  • Can be enhanced with logging in future versions

Error Handling and Safety

  • Syntax validation - Event code is parsed for syntax errors during compilation
  • Runtime errors - Errors in event handlers are caught by Phoenix LiveView
  • Parameter validation - Access to params should include nil checks
  • Socket state - Always return valid socket to maintain LiveView state

Performance Considerations

  • Event handlers are compiled as efficient pattern-matched functions
  • No runtime overhead for event dispatch
  • Direct access to socket assigns without serialization
  • Optimized for high-frequency user interactions

This function is called as the second stage in the compilation pipeline.

build_helpers(arg)

Builds helper functions for custom page logic.

Processes helper function definitions from the page configuration and compiles them into the LiveView module. Helper functions provide custom logic that can be called from within page templates and event handlers.

Parameters

  • {params, functions_ast} - Tuple containing:
    • params - Page configuration including :helpers list
    • functions_ast - Existing function ASTs from previous pipeline stages

Returns

Returns updated tuple {params, functions_ast} with helper functions added.

Helper Function Format

Each helper function should be defined as a map with:

  • :name - Function name as string (required)
  • :args - Function arguments as string (required)
  • :code - Function body as Elixir code string (required)

Examples

helpers: [
  %{
    name: "format_date",
    args: "date, format \\ :short",
    code: ~S'''
    case format do
      :short -> Calendar.strftime(date, "%m/%d/%Y")
      :long -> Calendar.strftime(date, "%B %d, %Y")
      :iso -> Date.to_iso8601(date)
      custom when is_binary(custom) -> Calendar.strftime(date, custom)
      _ -> Date.to_string(date)
    end
    '''
  },
  %{
    name: "calculate_total",
    args: "items, tax_rate \\ 0.08",
    code: ~S'''
    subtotal = Enum.reduce(items, 0, fn item, acc ->
      acc + (item.price * item.quantity)
    end)

    tax = subtotal * tax_rate
    subtotal + tax
    '''
  },
  %{
    name: "render_status_indicator",
    args: "status",
    code: ~S'''
    {class, icon, text} = case String.downcase(status) do
      "active" -> {"text-green-600", "check-circle", "Active"}
      "pending" -> {"text-yellow-600", "clock", "Pending"}
      "inactive" -> {"text-red-600", "x-circle", "Inactive"}
      "processing" -> {"text-blue-600", "refresh", "Processing"}
      _ -> {"text-gray-600", "question-mark-circle", "Unknown"}
    end

    ~H"""
    <div class={"flex items-center space-x-2 " <> class}>
      <svg class="w-4 h-4" data-icon={icon}>
        <!-- Icon SVG would be here -->
      </svg>
      <span><%= text %></span>
    </div>
    """
    '''
  },
  %{
    name: "generate_pagination",
    args: "current_page, total_pages, window \\ 2",
    code: ~S'''
    start_page = max(1, current_page - window)
    end_page = min(total_pages, current_page + window)

    pages = Enum.to_list(start_page..end_page)

    %{
      current: current_page,
      total: total_pages,
      pages: pages,
      prev: if(current_page > 1, do: current_page - 1),
      next: if(current_page < total_pages, do: current_page + 1),
      has_prev: current_page > 1,
      has_next: current_page < total_pages
    }
    '''
  }
]

Template Usage

Helper functions can be called directly from templates:

template = ~S'''
<div class="order-summary">
  <h2>Order Summary</h2>
  <div class="date">
    Order Date: <%= format_date(@order.inserted_at, :long) %>
  </div>

  <div class="items">
    <%= for item <- @order.items do %>
      <div class="item">
        <span><%= item.name %></span>
        <span>$<%= item.price %> x <%= item.quantity %></span>
      </div>
    <% end %>
  </div>

  <div class="total">
    Total: $<%= calculate_total(@order.items, @tax_rate) %>
  </div>

  <div class="status">
    <%= render_status_indicator(@order.status) %>
  </div>
</div>
'''

Event Handler Usage

Helper functions can also be called from event handlers:

events: [
  %{
    name: "change_page",
    code: ~S'''
    new_page = String.to_integer(params["page"])
    pagination = generate_pagination(new_page, socket.assigns.total_pages)

    {:noreply,
     socket
     |> assign(:current_page, new_page)
     |> assign(:pagination, pagination)}
    '''
  }
]

Advanced Helper Examples

helpers: [
  # Helper with pattern matching and guards
  %{
    name: "safe_divide",
    args: "a, b",
    code: ~S'''
    cond do
      b == 0 -> {:error, :division_by_zero}
      is_number(a) and is_number(b) -> {:ok, a / b}
      true -> {:error, :invalid_arguments}
    end
    '''
  },

  # Helper with HEEx components
  %{
    name: "render_breadcrumbs",
    args: "breadcrumbs",
    code: ~S'''
    breadcrumbs
    |> Enum.with_index()
    |> Enum.map(fn {crumb, index} ->
      if index == length(breadcrumbs) - 1 do
        ~H"<span class='breadcrumb-current'><%= crumb.title %></span>"
      else
        ~H"""
        <a href={crumb.url} class='breadcrumb-link'>
          <%= crumb.title %>
        </a>
        """
      end
    end)
    |> Enum.intersperse(~H" / ")
    '''
  },

  # Helper with external API calls
  %{
    name: "get_weather",
    args: "location",
    code: ~S'''
    case MyApp.WeatherAPI.get_current(location) do
      {:ok, weather} ->
        %{
          temperature: weather.temp,
          description: weather.description,
          icon: weather.icon_url
        }

      {:error, _} ->
        %{
          temperature: "N/A",
          description: "Weather unavailable",
          icon: "/images/weather-unknown.svg"
        }
    end
    '''
  }
]

Function Compilation

Helper functions are compiled as regular module functions with:

  • Full Elixir syntax support - Use any valid Elixir code
  • Access to imports - Phoenix.Component, HEEx, etc.
  • Pattern matching - Multiple function heads supported
  • Guard clauses - When conditions supported
  • Default arguments - Using \\ syntax
  • Private scope - Helper functions are public within the LiveView module

Error Handling

  • Syntax errors in helper code will prevent module compilation
  • Invalid arguments will cause compilation failures with detailed messages
  • Missing dependencies in helper code will be reported during compilation
  • Runtime errors in helpers will be caught and logged during template rendering

Performance Considerations

  • Helper functions are compiled once during module compilation
  • No runtime overhead for function calls within templates
  • Complex helpers should be optimized for template rendering performance
  • Consider caching expensive computations within helper implementations

Security Considerations

  • Helper functions have full access to the module's context
  • Validate inputs in helpers that process user data
  • Be cautious with helpers that make external API calls
  • Use proper escaping for helpers that return HTML content

This function is called as the fifth stage in the compilation pipeline.

build_info_handlers(arg)

Builds info handler functions for LiveView message processing.

Compiles info handler definitions into handle_info/2 functions that respond to messages sent to the LiveView process from other parts of the system.

Parameters

  • {params, functions_ast} - Tuple containing:
    • params - Page configuration including :infos list
    • functions_ast - Existing function ASTs from previous pipeline stages

Returns

Returns updated tuple {params, functions_ast} with info handler functions added.

Info Handler Format

Each info handler should be defined as a map with:

  • :name - Message pattern as string (required)
  • :code - Handler code as Elixir code string (required)

Examples

infos: [
  %{
    name: ":refresh_data",
    code: ~S'''
    data = MyApp.DataSource.get_latest()
    {:noreply, assign(socket, :data, data)}
    '''
  },
  %{
    name: "{:user_updated, user}",
    code: ~S'''
    if socket.assigns.user_id == user.id do
      {:noreply, assign(socket, :user, user)}
    else
      {:noreply, socket}
    end
    '''
  },
  %{
    name: "{:broadcast, :data_changed, payload}",
    code: ~S'''
    case payload do
      %{type: "products"} ->
        products = MyApp.Products.list_products()
        {:noreply, assign(socket, :products, products)}

      %{type: "users"} ->
        users = MyApp.Users.list_users()
        {:noreply, assign(socket, :users, users)}

      _ ->
        {:noreply, socket}
    end
    '''
  },
  %{
    name: "{:timeout, ref, :periodic_update}",
    code: ~S'''
    # Schedule next update
    timer_ref = Process.send_after(self(), {:timeout, make_ref(), :periodic_update}, 30_000)

    # Update data
    stats = MyApp.Stats.get_current()

    {:noreply,
     socket
     |> assign(:stats, stats)
     |> assign(:timer_ref, timer_ref)}
    '''
  }
]

Generated Functions

Each info definition creates a handle_info/2 function with pattern matching:

def handle_info(:refresh_data, socket) do
  data = MyApp.DataSource.get_latest()
  {:noreply, assign(socket, :data, data)}
end

def handle_info({:user_updated, user}, socket) do
  if socket.assigns.user_id == user.id do
    {:noreply, assign(socket, :user, user)}
  else
    {:noreply, socket}
  end
end

Message Sending

Info handlers can be triggered from anywhere in the system:

# From controllers, contexts, or other processes
send(live_view_pid, :refresh_data)
send(live_view_pid, {:user_updated, updated_user})

# From other LiveViews
Phoenix.PubSub.broadcast(MyApp.PubSub, "data_updates",
  {:broadcast, :data_changed, %{type: "products"}})

# Scheduled messages
Process.send_after(self(), {:timeout, make_ref(), :periodic_update}, 5000)

Common Use Cases

Real-time Updates

infos: [
  %{
    name: "{:live_update, data}",
    code: ~S'''
    {:noreply, assign(socket, :live_data, data)}
    '''
  }
]

PubSub Integration

# In mount/3 or event handler:
Phoenix.PubSub.subscribe(MyApp.PubSub, "updates")

# Info handler:
infos: [
  %{
    name: "{:update_notification, payload}",
    code: ~S'''
    {:noreply, put_flash(socket, :info, payload.message)}
    '''
  }
]

Periodic Tasks

infos: [
  %{
    name: ":tick",
    code: ~S'''
    # Schedule next tick
    Process.send_after(self(), :tick, 1000)

    # Update timestamp
    {:noreply, assign(socket, :current_time, DateTime.utc_now())}
    '''
  }
]

Available Variables

Info handler code has access to:

  • socket - The LiveView socket with assigns and state
  • Pattern-matched message variables (e.g., user in {:user_updated, user})
  • All imported functions from Phoenix.LiveView
  • Helper functions defined in the same module

Return Values

Info handlers must return valid LiveView responses:

  • {:noreply, socket} - Update socket and re-render
  • {:noreply, socket, timeout} - Update with timeout for hibernation

Catch-All Handler

A catch-all handle_info/2 is automatically added that:

  • Handles any unmatched messages gracefully
  • Returns {:noreply, socket} to prevent crashes
  • Can be enhanced with logging in future versions

Error Handling and Safety

  • Pattern matching - Use specific patterns to avoid unexpected messages
  • Guard clauses - Add guards for additional message validation
  • Defensive coding - Check socket assigns before accessing nested data
  • Message validation - Validate message payload structure in handler code

Performance Considerations

  • Info handlers are compiled as efficient pattern-matched functions
  • Direct message passing without serialization overhead
  • Optimized for high-frequency real-time updates
  • Process mailbox efficiency depends on message pattern specificity

This function is called as the third stage in the compilation pipeline.

build_module(params)

Builds a complete LiveView module AST from configuration parameters.

This is the primary entry point for the LiveView compilation process. It orchestrates the entire compilation pipeline to transform page configuration into a complete Phoenix LiveView module AST ready for compilation.

Parameters

  • params - Map containing page configuration with keys:
    • :title - Page title/name (required)
    • :page_module - Target module name (required)
    • :template - HEEx template string (required)
    • :path - Page path/URL (optional)
    • :section - Page section/category (optional)
    • :events - List of event handler definitions (optional)
    • :infos - List of info handler definitions (optional)
    • :helpers - List of helper function definitions (optional)
    • :description - The page's meta description, stored as page.seo.description (optional)
    • :seo_tags - List of SEO meta tags (optional)
    • :schema - List of structured data schema (optional)
    • :site, :site_id, :host - Site identification (optional)
    • :permissions - Access control permissions (optional)
    • :extra - Additional metadata (optional)
    • :priority - Page priority for ordering (optional)
    • :format - Template format (:heex, default)

Returns

Returns the complete AST for the LiveView module that can be compiled using MishkaCmsCore.Runtime.Compilers.ModuleCompiler.compile_module/1.

Examples

# Basic interactive page
params = %{
  title: "Counter Page",
  page_module: MyApp.Pages.Counter,
  template: ~S'''
  <div>
    <h1>Count: <%= @count %></h1>
    <button phx-click="increment">+</button>
    <button phx-click="decrement">-</button>
  </div>
  ''',
  events: [
    %{name: "increment", code: "{:noreply, update(socket, :count, &(&1 + 1))}"},
    %{name: "decrement", code: "{:noreply, update(socket, :count, &(&1 - 1))}"}
  ]
}

ast = build_module(params)

# Complex dashboard page
params = %{
  title: "Analytics Dashboard",
  page_module: MyApp.Pages.Dashboard,
  template: dashboard_template,
  events: dashboard_events,
  infos: dashboard_message_handlers,
  helpers: dashboard_helpers,
  seo_tags: [%{name: "description", content: "Analytics dashboard"}]
}

ast = build_module(params)

Compilation Pipeline

The function processes page data through these stages:

  1. Assigns building - Creates page information and metadata functions
  2. Page forms - Lifts the template's own forms into __page_forms__/0 and generates their change and submit handlers
  3. Event handlers - Compiles interactive event handling functions
  4. Info handlers - Compiles message processing functions
  5. Render functions - Compiles template and creates render functions
  6. Helper functions - Processes and compiles helper functions
  7. Module creation - Assembles final module AST with imports

Each stage adds specific functionality to the final compiled LiveView module.

build_page_forms(arg)

Compiles the page's own forms into __page_forms__/0, and generates the change and submit handlers for every form whose event names the author has NOT declared.

The template is parsed ONCE PER COMPILE — a save, not a keystroke — so a page never re-reads its own markup to answer an event. The specs are put back into params under :page_forms for the same reason: build_renders/1 needs nothing from them but "does this page have a form", and asking PageForms.declared/1 again for that boolean tokenized, parsed and walked the whole page template a second time to re-derive a fact this stage already holds.

An author who declares an event of the same name on the Events tab suppresses generation for it and owns the clause outright; they keep the whole seam by calling MishkaCmsCore.Runtime.PageForm.validate/4 or MishkaCmsCore.Runtime.PageForm.submit/5 from their own body. Generation is skipped rather than the clause being shadowed, because two handle_event/3 heads with the same name would make the author's unreachable and their page would silently stop responding.

The author's entries are first renamed with MishkaCmsCore.Runtime.PageBuilder.PageForms.follow/2 and put back into params[:events], so an entry stored under a renamed form's old tagged event compiles under the form's current one, and an entry for a deleted form never answers a new form that happens to reuse its seed.

Runs before build_event_handlers/1, which appends the author's clauses and then the catch-all, so every generated clause is reachable and the catch-all still answers everything else.

Generated Functions

def __page_forms__, do: [%{assign: :contact_form, as: "contact", ...}]

def handle_event("contact-k7m2q-validate", params, socket) do
  {:noreply, PageForm.validate(socket, __page_forms__(), "contact-k7m2q-validate", params)}
end

def handle_event("contact-k7m2q-submit", params, socket) do
  PageForm.submit(socket, __page_forms__(), "contact-k7m2q-submit", params)
end

build_page_messages(arg)

Gives a page the handle_event/3 clauses for whatever server actions it uses.

What the author did

Picked an action in the builder's On click panel — "Show a message" is core's one — which wrote a JS.push(…) into the element's phx-click. That is the whole of the client half, and it is the same push a developer would write by hand.

Why the registry rather than a name

Because the list is not core's to close. MishkaCmsCore.Runtime.PageBuilder.Interactions collects every action through builder:interactions, and each server one names the event it pushes and the {module, function} that answers it — so an installed package's action compiles into a page the same way core's does, with nothing here knowing that package exists.

The module named is the action's own RUNTIME module, never the editor code that wrote the click: what goes here is compiled into every published page using the action.

Why every page, and not only the ones whose markup carries the push

Because a page's markup is not where the click has to be. An author who puts the button inside a COMPONENT saves it in the component's markup; the page that uses that component says only <.component component_name="promo-card" />, and the word the gate was looking for is nowhere in it. The card rendered, the button rendered, the visitor clicked, and the event arrived at a module with no clause for it — swallowed by the catch-all, nothing on screen, nothing in the log. The same is true of a button in a layout, and of one on an error page.

Looking harder was not an option worth the price: the page would have to load and scan every component it names, on every compile, to answer a question whose wrong answer is silence.

So the clause is always there. A handle_event/3 that nothing pushes costs nothing — it is three lines of bytecode in a module that already exists, no process, no query, and never runs. An author who declares an event of that name on the Events tab still suppresses it, exactly as they do for a form's: theirs is the more specific answer, and generating a second clause would make it unreachable.

build_page_state(arg)

Compiles the page's state into the assigns it starts with — see MishkaCmsCore.Runtime.PageState, which the mounting LiveView calls.

Generated

def __page_state__, do: %{count: 0, roles: roles()}
def __page_state_keys__, do: [:count, :roles]

__page_state__/0 is evaluated each time a visitor mounts the page, so an expression may call the page's helpers and a timestamp is fresh. __page_state_keys__/0 is a literal, read without running any of it. Both are emitted whether or not anything is declared.

Each value passes through the same signature check as a handler: unsigned code, where signing is on, compiles to nil. The names become atoms here, at compile time, from rows that passed MishkaCmsCore.Runtime.Resources.Validations.PageStateNames; anything that is not a name is skipped rather than minted.

build_renders(arg)

Builds render functions for LiveView template rendering.

Compiles the HEEx template and creates render functions that handle template rendering within the LiveView lifecycle. The functions integrate with Phoenix LiveView's rendering system and provide access to compiled templates.

Parameters

  • {params, functions_ast} - Tuple containing:
    • params - Page configuration with template and metadata
    • functions_ast - Existing function ASTs from previous pipeline stages

Returns

Returns updated tuple {params, functions_ast} with render functions added.

Generated Functions

render/1 - Main template rendering function

The primary render function that Phoenix LiveView calls to render the page. Takes assigns and returns the rendered template.

def render(assigns) when is_map(assigns) do
  [primary] = templates(assigns)
  primary
end

templates/1 - Template collection function

Returns all compiled templates for the page. Currently returns a single primary template but structured to support multiple templates in the future.

def templates(assigns) when is_map(assigns) do
  [compiled_template]
end

Template Compilation

Templates are compiled using Phoenix's HEEx engine with:

  • XSS protection through automatic escaping
  • LiveView integration for dynamic updates
  • Component support for MishkaCMS runtime components
  • Proper error handling with detailed compilation feedback

Template Structure

The compiled template has access to:

  • All assigns - LiveView socket assigns
  • Helper functions - Custom page helper functions
  • MishkaCMS components - Runtime compiled components
  • Phoenix components - Standard Phoenix UI components

Example Templates

# Interactive template with event bindings
template = ~S'''
<div class="page">
  <h1><%= @title %></h1>

  <div class="counter">
    <button phx-click="decrement">-</button>
    <span><%= @count %></span>
    <button phx-click="increment">+</button>
  </div>

  <div class="items">
    <%= for item <- @items do %>
      <div class="item" id={"item-#{item.id}"}>
        <h3><%= item.name %></h3>
        <p><%= item.description %></p>
        <button phx-click="delete" phx-value-id={item.id}>
          Delete
        </button>
      </div>
    <% end %>
  </div>
</div>
'''

# Template with helper functions and components
template = ~S'''
<div class="dashboard">
  <header>
    <h1><%= format_title(@page_title, @site_name) %></h1>
    <div class="stats">
      <%= render_stats(@statistics) %>
    </div>
  </header>

  <main>
    <.component name="data_table"
               data={@table_data}
               columns={@columns} />
  </main>
</div>
'''

Template Engine Configuration

The compilation uses these settings:

  • Engine: Phoenix.LiveView.TagEngine for HEEx support
  • Tag Handler: Phoenix.LiveView.HTMLEngine for proper HTML handling
  • File Context: Generated file name for debugging
  • Source Trimming: Enabled for cleaner output
  • Line Tracking: Accurate line numbers for errors

Error Handling

Template compilation errors include:

  • Syntax errors with line numbers
  • Invalid HEEx expressions
  • Missing closing tags
  • Malformed component calls
  • Undefined variable references

Errors are propagated up with full context for debugging.

Integration with LiveView Lifecycle

The render function integrates with Phoenix LiveView's rendering cycle:

  1. Socket assigns are passed to render/1
  2. Template compilation happens once during module compilation
  3. Dynamic updates are handled by LiveView's diff engine
  4. Component integration allows nested component rendering

Performance Considerations

  • Template compilation is performed once during module compilation
  • Render function is optimized for repeated calls
  • LiveView's diff engine minimizes DOM updates
  • Helper function calls are direct module function calls

This function is called as the fourth stage in the compilation pipeline.

build_uploads(arg)

Compiles the page's upload declarations into __page_uploads__/0, for the LiveView that mounts it to register.

allow_upload/3 is a Phoenix.LiveView function, and until this stage a compiled page had nowhere to call one: the compiler emits no mount/3, so @uploads never existed and chelekom-file-field's live mode — the one stateful capability the kit already ships — had nothing to be handed. A live component cannot supply it either; allow_upload/3 is not one of its functions.

Why the module only says what it wants, and registers nothing

A compiled page is not the LiveView that renders it: MishkaCmsWeb.RuntimeLive mounts, compiles the module and then calls its render/1 and handle_event/3. So a mount/3 or an on_mount hook emitted here would never run, and @uploads would never exist — which is how this was first written and what a page on the dev site said, in a 500 reading key :uploads not found.

MishkaCmsCore.Runtime.PageUpload.allow/2 is called by the mounting LiveView and reads this.

Generated

def __page_uploads__, do: [%{name: :attachment, accept: "image/*", max_entries: 2}]

Emitted whether or not anything is declared, so a reader can ask without function_exported?/3. The name is turned into an atom HERE, at compile time, and never at runtime: the set of atoms is then bounded by what authors have saved rather than by what visitors send.

confirmable(template, specs \\ nil, layout_says? \\ false)

@spec confirmable(String.t() | nil, [map()] | nil, boolean() | nil) ::
  String.t() | nil

The template a page really compiles: its own markup, with the confirmation region in front of it when the page declares a form.

Public because THE CLASS SCANNER HAS TO READ THE SAME STRING. Tailwind matches class-shaped text in the .template files MishkaCmsCore.Runtime.Handlers.BootHandler.create_template_file/4 writes, and that file was built from the STORED template while the region was prepended on the way into Phoenix.LiveView.TagEngine.compile/2 — so bg-emerald-50, max-w-3xl, shadow-lg and the rest existed in no scanned file, no rule was generated for them, and the confirmation rendered as bare unstyled body text. Same class of bug as an attribute's default: markup that only ever exists after the scanner has read the file.

specs is what build_page_forms/1 already read out of the same template. Passing nil re-reads it, which is what a caller holding only a stored template has to do.

create_module_ast(arg)

Creates the final LiveView module AST with all components assembled.

This is the final stage of the compilation pipeline that assembles all generated function ASTs into a complete LiveView module definition with proper imports and structure.

Parameters

  • {params, functions_ast} - Tuple containing:
    • params - Page configuration with :page_module target
    • functions_ast - All compiled function ASTs from previous stages

Returns

Returns the complete module AST ready for compilation.

Generated Module Structure

The final module includes:

defmodule TargetModule do
  use MishkaCmsWeb, :live_view  # whatever :web_module names
  import MishkaCmsCore.Runtime.LiveViewHelpers, only: [component: 1, component: 2]

  # Page information functions
  def __page_information__() do ... end
  def __page_information__(key) when is_atom(key) do ... end
  def __page_information__(keys) when is_list(keys) do ... end
  def __page_information__(key) when is_binary(key) do ... end

  # Event handlers
  def handle_event(event, params, socket) do ... end

  # Info handlers
  def handle_info(message, socket) do ... end

  # Render functions
  def render(assigns) when is_map(assigns) do ... end
  def templates(assigns) when is_map(assigns) do ... end

  # Helper functions (if defined)
  def helper_function_name(args) do ... end
end

Module Dependencies

The generated module automatically includes:

  • MishkaCmsWeb :live_view - Full Phoenix LiveView functionality
  • MishkaCmsCore.Runtime.LiveViewHelpers - Runtime component access

These imports enable the LiveView to:

  • Use LiveView features like event handling and socket management
  • Access Phoenix.Component functionality and HEEx templates
  • Call runtime-compiled MishkaCMS components
  • Handle user interactions and real-time updates

Example Generated Module

defmodule MyApp.Pages.ProductList do
  use MishkaCmsWeb, :live_view  # whatever :web_module names
  import MishkaCmsCore.Runtime.LiveViewHelpers, only: [component: 1, component: 2]

  def __page_information__() do
    %{
      page: %{title: "Product List", path: "/products"},
      private: %{site: "main", format: :heex},
      acl: %{permissions: []},
      extra: nil
    }
  end

  def handle_event("search", %{"query" => query}, socket) do
    results = MyApp.Products.search(query)
    {:noreply, assign(socket, :results, results)}
  end

  def render(assigns) do
    # Compiled template code here
  end

  def format_price(price) do
    # Helper function code here
  end
end

Integration Points

The module structure ensures compatibility with:

  • Phoenix LiveView - Standard LiveView module pattern
  • Phoenix Router - Can be mounted with live/3 macro
  • MishkaCMS Runtime - Component and page system integration
  • Phoenix Components - Full component library access
  • Elixir OTP - Standard module loading and hot code swapping

Compilation Process

After this function returns the AST:

ast = create_module_ast({params, functions_ast})
{:ok, module} = MishkaCmsCore.Runtime.Compilers.ModuleCompiler.compile_module(ast)

The module is then available for use in routes, testing, and the application.

LiveView Lifecycle Integration

The generated module works seamlessly with Phoenix LiveView's lifecycle:

# In router
live "/dashboard", MyApp.Pages.Dashboard

# LiveView handles mount, events, and rendering automatically
# Page information is available for metadata and SEO
# Event handlers respond to user interactions
# Info handlers process real-time updates

This is the final stage in the compilation pipeline and produces the complete LiveView module ready for runtime compilation and usage.

form_handler_sample(kind, event)

@spec form_handler_sample(:validate | :submit, String.t()) :: String.t()

The default code for a form's event on the Events tab: print the params, then run form_handler_source/2, so the form keeps working.

form_handler_source(atom, event)

@spec form_handler_source(:validate | :submit, String.t()) :: String.t()

The body of a form's generated handle_event/3 clause, as source: kind is :validate or :submit, and event is the event name the clause matches, which is also how the body finds its form in __page_forms__/0.

helper_args(args)

@spec helper_args(String.t() | nil) :: [Macro.t()]

The quoted argument list of a helper whose args is args, for def name(unquote_splicing(…)): "" or nil is no argument, "assigns" one, "assigns, suffix" two, defaults included.

Read as the argument list of a call, so args must parse as one. Pages, layouts and error pages all build their helpers' heads with it.

LiveViewCompiler.helper_args("")
#=> []

LiveViewCompiler.helper_args("assigns, size")
#=> [{:assigns, [line: 1], nil}, {:size, [line: 1], nil}]

make_env(imports \\ [])

Creates compilation environment for template and code processing.

Builds a macro environment with proper imports and context for HEEx template compilation and code evaluation. This environment determines what modules and functions are available within compiled templates and code.

Parameters

  • imports - List of modules to import (default: [])

Returns

Returns a Macro.Env struct configured for compilation with the specified imports.

Environment Configuration

The environment includes:

  • Base Environment - Standard Elixir compilation environment
  • Dynamic Imports - Configurable module imports
  • Error Handling - Strict error handling for import failures

Import Processing

Each import is processed with:

  1. Module availability check - Verifies module is loaded
  2. Safe import definition - Uses Macro.Env.define_import/3
  3. Strict error handling - Raises on import failures

Common Usage

# Template compilation environment
env = make_env([Phoenix.Component, Phoenix.HTML])

# Code evaluation environment
env = make_env([Phoenix.LiveView, MyApp.Helpers])

Error Handling

Import failures will raise exceptions with detailed error information. This ensures that compilation fails early if required modules are not available.

render_html_template(template, assigns \\ %{})

@spec render_html_template(String.t(), map()) :: String.t() | {:error, term()}

Renders template with the classic Phoenix.HTML.Engine (not HEEx), matching how the runtime serves error-page templates (MishkaCmsCore.Runtime.Compilers.ErrorCompiler compiles them with the same engine). Unlike render_template/2, this engine does not tokenize HTML structurally, so it allows EEx interpolation inside attribute values (content="<%= ... %>") — which a server-rendered root layout relies on and which HEEx rejects.

Returns the rendered HTML string, or {:error, message} on failure.

render_template(template, assigns \\ %{})

@spec render_template(String.t(), map()) :: String.t() | {:error, term()}

Renders isolated HEEx template with assigns for runtime operations not page compiling.

Example

render_template("<div>{@name}</div>", %{name: "John"})
#=> "<div>John</div>"

render_template("<.link patch='/users'>{@text}</.link>", %{text: "Users"})
#=> "<a href="/users" data-phx-link="patch" data-phx-link-state="push">Users</a>"

render_template!(template, assigns \\ %{})

@spec render_template!(String.t(), map()) :: String.t()

Renders a HEEx template with assigns, raises on error.

shows_messages?(arg1)

@spec shows_messages?(map() | nil) :: boolean()

Whether a layout already shows messages — what confirmable/3 takes as its third argument.

Asked of the parsed template

Because a wrong yes is a visitor who is told NOTHING, and this used to be String.contains?(template, "@flash") — which said yes to a layout that merely MENTIONED it: in an HTML comment, inside a string, in a <pre> showing somebody how to write one. No spelling of that check tells those apart, because the difference is not in the characters. So the template is tokenised and its expressions are parsed, and an assign is a node rather than a run of text.

The two answers that are not about a template

nil is a page with no layout of its own, which MishkaCmsWeb.RuntimeLive renders inside Layouts.app — and that carries a <.flash_group>, so it counts as a layout that shows messages.

Anything unreadable counts as one that does not, so the page keeps its own region: being told twice is a blemish, being told nothing is the bug.

untouched_handler?(kind, code, event)

@spec untouched_handler?(:validate | :submit, term(), term()) :: boolean()

Whether code is form_handler_source/2 or form_handler_sample/2 for event as written, whitespace aside. A quoted name carrying the same token and kind as event counts as event, so an untouched body still reads as untouched after its form is renamed.