Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol heex.ex
Raw

lib/raxol/heex.ex

defmodule Raxol.HEEx do
@moduledoc """
**EXPERIMENTAL** -- HEEx template integration for Raxol.
Allows using Phoenix HEEx templates directly in terminal applications,
with terminal-specific components and styling.
`Raxol.HEEx.Components` provides real Phoenix Component implementations
that emit HTML with `data-terminal-component` attributes.
`compile_heex_for_terminal/2` parses this HTML and builds Raxol widget
trees compatible with the rendering pipeline.
## Example
defmodule MyHEExApp do
use Raxol.HEEx
def render(assigns) do
~H\"\"\"
<.terminal_box padding={2} border="single">
<.terminal_text color="green" bold>
Hello, <%= @name %>!
</.terminal_text>
<.terminal_button phx-click="click_me" class="primary">
Click me!
</.terminal_button>
</.terminal_box>
\"\"\"
end
end
"""
require Logger
defmacro __using__(_opts) do
if Code.ensure_loaded?(Phoenix.Component) do
quote do
import Phoenix.Component
import Raxol.HEEx
import Raxol.HEEx.Components
end
else
quote do
import Raxol.HEEx
end
end
end
@doc """
Compile HEEx templates for terminal rendering.
Takes rendered HEEx HTML (a string with `data-terminal-component` attributes)
and converts it to Raxol widget tree maps compatible with the rendering pipeline.
## Parameters
- `template` - Raw HEEx template string (before Phoenix rendering)
- `assigns` - Map of template assigns for variable interpolation
## Returns
A widget tree map: `%{type: atom, children: list, style: map, ...}`
"""
def compile_heex_for_terminal(template, assigns) do
template
|> interpolate_assigns(assigns)
|> parse_html_to_widget_tree()
end
# Variable interpolation for raw template strings
defp interpolate_assigns(template, assigns) do
Enum.reduce(assigns, template, fn {key, value}, acc ->
pattern = "<%= @#{key} %>"
String.replace(acc, pattern, to_string(value))
end)
end
@doc """
Convert rendered HTML (from Phoenix Components) to a Raxol widget tree.
Parses HTML with `data-terminal-component` attributes and builds the
corresponding widget tree structure that the rendering pipeline expects.
"""
def parse_html_to_widget_tree(html) when is_binary(html) do
html
|> tokenize()
|> build_tree()
|> convert_nodes()
end
# Tokenizer: convert HTML string to a flat list of tokens
defp tokenize(html) do
tokenize(html, [])
end
defp tokenize("", acc), do: Enum.reverse(acc)
defp tokenize(html, acc) do
cond do
# Self-closing tag: <input ... />
match = Regex.run(~r/\A<(\w+)((?:\s+[^>]*?)?)\/>/s, html) ->
[full, tag, attrs_str] = match
rest = String.slice(html, String.length(full)..-1//1)
attrs = parse_attributes(attrs_str)
tokenize(rest, [{:self_closing, tag, attrs} | acc])
# Opening tag: <tag ...>
match = Regex.run(~r/\A<(\w+)((?:\s+[^>]*?)?)>/s, html) ->
[full, tag, attrs_str] = match
rest = String.slice(html, String.length(full)..-1//1)
attrs = parse_attributes(attrs_str)
tokenize(rest, [{:open, tag, attrs} | acc])
# Closing tag: </tag>
match = Regex.run(~r/\A<\/(\w+)\s*>/s, html) ->
[full, tag] = match
rest = String.slice(html, String.length(full)..-1//1)
tokenize(rest, [{:close, tag} | acc])
# Text content
match = Regex.run(~r/\A([^<]+)/s, html) ->
[full, text] = match
rest = String.slice(html, String.length(full)..-1//1)
trimmed = String.trim(text)
if trimmed == "" do
tokenize(rest, acc)
else
tokenize(rest, [{:text, trimmed} | acc])
end
# Fallback: skip one character
true ->
tokenize(String.slice(html, 1..-1//1), acc)
end
end
# Parse HTML attributes from a string
defp parse_attributes(attrs_str) do
# Match key="value" and key={value} and standalone boolean attrs
Regex.scan(~r/([\w\-]+)(?:=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/, attrs_str)
|> Enum.map(fn
[_, key, value, "", ""] -> {key, value}
[_, key, "", value, ""] -> {key, value}
[_, key, "", "", value] -> {key, value}
[_, key] -> {key, "true"}
[_, key, value] -> {key, value}
end)
|> Map.new()
end
# Build a tree from flat tokens
defp build_tree(tokens) do
{nodes, _rest} = build_children(tokens, nil)
nodes
end
defp build_children([], _stop_tag), do: {[], []}
defp build_children([{:close, tag} | rest], stop_tag) when tag == stop_tag do
{[], rest}
end
defp build_children([{:close, _tag} | rest], stop_tag) do
# Mismatched close tag, skip it
build_children(rest, stop_tag)
end
defp build_children([{:text, text} | rest], stop_tag) do
{siblings, remaining} = build_children(rest, stop_tag)
{[{:text_node, text} | siblings], remaining}
end
defp build_children([{:self_closing, tag, attrs} | rest], stop_tag) do
node = {:element, tag, attrs, []}
{siblings, remaining} = build_children(rest, stop_tag)
{[node | siblings], remaining}
end
defp build_children([{:open, tag, attrs} | rest], stop_tag) do
{children, after_close} = build_children(rest, tag)
node = {:element, tag, attrs, children}
{siblings, remaining} = build_children(after_close, stop_tag)
{[node | siblings], remaining}
end
# Convert parsed tree nodes to Raxol widget tree maps
defp convert_nodes(nodes) when is_list(nodes) do
widgets =
Enum.map(nodes, &convert_node/1)
|> Enum.reject(&is_nil/1)
case widgets do
[single] -> single
multiple -> %{type: :column, children: multiple, style: %{}}
end
end
defp convert_node({:text_node, text}) do
%{type: :text, content: text, style: %{}, id: nil}
end
defp convert_node({:element, _tag, attrs, children}) do
component_type = Map.get(attrs, "data-terminal-component")
convert_component(component_type, attrs, children)
end
defp convert_component("box", attrs, children) do
style = decode_style(Map.get(attrs, "data-style", "{}"))
%{
type: :box,
children: convert_children(children),
style: style,
id: Map.get(attrs, "id"),
padding: Map.get(style, "padding", 0),
border: Map.get(style, "border", :none)
}
end
defp convert_component("row", attrs, children) do
%{
type: :flex,
direction: :row,
children: convert_children(children),
gap: parse_int(Map.get(attrs, "data-gap", "0")),
justify: parse_justify(Map.get(attrs, "data-justify", "start")),
align: parse_align(Map.get(attrs, "data-align", "start")),
style: %{},
id: Map.get(attrs, "id")
}
end
defp convert_component("column", attrs, children) do
%{
type: :flex,
direction: :column,
children: convert_children(children),
gap: parse_int(Map.get(attrs, "data-gap", "0")),
align: parse_align(Map.get(attrs, "data-align", "start")),
style: %{},
id: Map.get(attrs, "id")
}
end
defp convert_component("text", attrs, children) do
style = decode_style(Map.get(attrs, "data-style", "{}"))
content = extract_text_content(children)
%{
type: :text,
content: content,
style: style,
id: Map.get(attrs, "id")
}
end
defp convert_component("button", attrs, children) do
content = extract_text_content(children)
%{
type: :button,
text: content,
style: %{},
id: Map.get(attrs, "id"),
on_click: Map.get(attrs, "phx-click"),
disabled: Map.get(attrs, "disabled") == "true"
}
end
defp convert_component("input", attrs, _children) do
%{
type: :text_input,
value: Map.get(attrs, "value", ""),
placeholder: Map.get(attrs, "placeholder", ""),
style: %{},
id: Map.get(attrs, "id"),
on_change: Map.get(attrs, "phx-change")
}
end
defp convert_component("progress", attrs, children) do
content = extract_text_content(children)
%{
type: :text,
content: content,
style: %{},
id: Map.get(attrs, "id")
}
end
defp convert_component("divider", attrs, children) do
content = extract_text_content(children)
%{
type: :text,
content: content,
style: %{fg: parse_color_attr(Map.get(attrs, "data-color"))},
id: Map.get(attrs, "id")
}
end
# Unknown component type -- render children as a generic container,
# or extract text content if it's a leaf element
defp convert_component(nil, _attrs, children) do
converted = convert_children(children)
case converted do
[] -> nil
[single] -> single
multiple -> %{type: :column, children: multiple, style: %{}}
end
end
defp convert_component(_unknown, _attrs, children) do
convert_component(nil, %{}, children)
end
defp convert_children(children) do
children
|> Enum.map(&convert_node/1)
|> Enum.reject(&is_nil/1)
end
defp extract_text_content(children) do
children
|> Enum.map(fn
{:text_node, text} -> text
{:element, _, _, nested} -> extract_text_content(nested)
end)
|> Enum.join(" ")
|> String.trim()
end
defp decode_style(json) when is_binary(json) do
case Jason.decode(json) do
{:ok, map} -> map
_ -> %{}
end
rescue
_ -> %{}
end
defp decode_style(_), do: %{}
defp parse_int(val) when is_binary(val) do
case Integer.parse(val) do
{n, _} -> n
:error -> 0
end
end
defp parse_int(val) when is_integer(val), do: val
defp parse_int(_), do: 0
defp parse_justify("start"), do: :start
defp parse_justify("center"), do: :center
defp parse_justify("end"), do: :end_
defp parse_justify("between"), do: :space_between
defp parse_justify(_), do: :start
defp parse_align("start"), do: :start
defp parse_align("center"), do: :center
defp parse_align("end"), do: :end_
defp parse_align(_), do: :start
defp parse_color_attr(nil), do: :default
defp parse_color_attr(color) when is_binary(color) do
try do
String.to_existing_atom(color)
rescue
_ -> :default
end
end
defp parse_color_attr(_), do: :default
end