X.Component

Component-based HTML templates for Elixir/Phoenix, inspired by Vue.
Zero-dependency. Framework/library agnostic. Optimized for Phoenix and Gettext.

x_component

Installation

def deps do
  [
    {:x_component, "~> 0.1.0"}
  ]
end

Features

Declarative HTML template syntax close to Vue.
Compile time errors and warnings.
Type checks with dialyzer specs.
Template code formatter.
Inline, context-aware components.
Smart attributes merge.
Decorator components.
Fast compilation and rendering.
Optimized for Gettext/Phoenix/ElixirLS.
Component generator task.

Template Syntax

See more examples here.

~X"""
<body>
  <!-- Body -->
  <div class="container">
    <Breadcrumbs
      :crumbs=[
        %{to: :root, params: [], title: "Home", active: false},
        %{to: :form, params: [], title: "Form", active: true}
      ]
      data-breadcrumbs
    />
    <Form :action='"/book/" <> to_string(book.id)'>
      {{ @message }}
      <FormInput
        :label='"Title"'
        :name=":title"
        :record="book"
      />
      <FormInput
        :name=":body"
        :record="book"
        :type=":textarea"
      />
      <RadioGroup
        :name=":type"
        :options=["fiction", "bussines", "tech"]
        :record="book"
      />
    </Form>
  </div>
</body>
"""

Tags

Static

<div>
  <meta item="example">
  <span />
</div>

Dynamic

<X :is="tag_name" />

Interpolations

Safe

<div>{{ message }}</div>

Unsafe

<div>{{= html_string }}</div>

Attributes

Static

<button class="d-flex" data-item="1" />

Dynamic

<input :class="[active: item.active]" class="form" data-item="1">
<input :class="item.classes" class="form" data-item="1">
<input :attrs=%{"class" => %{"active" => item.classes, "form" => true}, "data-item" => 1}>

Directives

x-for

x-for is compiled into Elixir for list comprehensions.

<ul>
  <li x-for="i <- [1, 2, 3, 4], i > 2">{{ i }}</li>
</ul>

x-if, x-else, x-else-if, x-unless

<div x-unless="is_nil(day)">
  <span x-if="day == 1">Today</span>
  <span x-else-if="day == 2">Tomorrow</span>
  <span x-else>In the future</span>
<div>

Comments

<!-- Example -->

Components

Assigns

Assigns can be defined using Elixir typespecs syntax:

defmodule Example do
  use X.Component,
    assigns: %{
      :conn => Conn.Plug.t(),
      required(:book) => map(),
      optional(:label) => nil | false | String.t(),
    },
    template: ~X"""
    <div....

By default all assigns are required. Optional assigns can be defined with optional map key typespec.

:asng="expr" dynamic attribute syntax is used to pass assigns to the component:

<Example :conn="conn" :book="book" />

Also, assigns can be passed as a map via the :assigns dynamic attr:

<Example :assigns=%{conn: conn, book: book} />

Assigns can be invoked on the template as local variables:

<div>{{ book.title }}</div>

All assigns can be fetched on the template via @assigns macro syntax.

<div>{{ inspect(@assigns) }}</div>

Dynamic components

Component can be rendered dynamicaly from Elixir expresion using special X tag with :component attribute:

<X :component="component_module" />

Decorator components

A simple decorator component would look like:

defmodule Form do
  use X.Component,
    assigns: %{
      :action => String.t(),
      :method => String.t() | atom()
    },
    template: ~X"""
    <form
      :attrs="@attrs"
      :action="action"
      :method="method"
      class="base-form"
    > {{= yield }}
    </form>
    """
end

:attrs="@attrs" is used to specify which HTML tag should be decorated (in Vue it's set to the root tag implicitly).

defmodule Index do
  use X.Component,
    template: ~X"""
    <Form
      :action='"/books"'
      :method='"get"'
      class="example-class"
    >
      <label>Title</label>
      <input name="title">
    </Form>
    """
end

Nested nodes are passed to the yield variable of the child component.
It's important to use the unsafe ({{=) interpolation with yield to avoid HTML escaping.

Decorator components are fast due to the inline compilation.

Inline compilation

By default, all components are rendered using the inline method. It means that instead of rendering nested components with a render function it inserts nested components AST into the parent component AST. This approach allows to optimize parent component AST for faster rendering. Decorator component example from the previous paragraph will be compiled entirely into Elixir string in compile time:

iex> Index.template_ast()
"<form action=\"/books\" method=\"get\" class=\"base-form example-class\"> <label>Title</label> <input name=\"title\"> </form>"

Also, makes it possible to fetch parent component assigns from the child component via @var syntax, without passing the assigns explicitly.

  <a
    :href="router(@conn, to, params)"
  > {{ yield }}
  </a>

Inline compilation method is not supported by dynamic components.

Compilation method can be adjusted via the application configs:

config :x_component,
  compile_inline: true

Smart attributes merge

X template compiler uses special rules for style and class attributes. Instead of overriding values it merges them into a list of classes and styles:

defmodule Button do
  use X.Component,
    assigns: %{
      optional(:submit) => nil | boolean()
    },
    template: ~X"""
    <button :attrs="@attrs" :class=[submit: submit] class="btn">Submit</button>
    """
end
~X"""
<Button
  :submit="true"
  :class=[{"btn-default", true}]
  class="btn-lg"
/>
"""
<button class="btn submit btn-lg btn-default">Submit</button>

Style or class can be removed by passing false to the child component:

~X"""
<Button
  :submit="true"
  :class=[{"btn", false}, {"x-btn", true}]
  class="btn-lg"
/>
"""
<button class="submit btn-lg x-btn">Submit</button>

Template formatter

Formatter task uses settings from .formatter.exs by default. All project files can be formatted with:

mix x.format

Also, formatter task can be used to format a specific file:

mix x.format path/to/file.ex

Generator

New component files can be generated with:

mix x.gen Users.Show

Generator settings can be adjusted via :x_component application configs:

config :x_component,
  root_path: "lib/app_web/components",
  root_module: "AppWeb.Components",
  generator_template: """
    use X.Template
  """

Phoenix integration

  • Remove :phoenix_html library (optional).
  • Add :x_component application configs to the config/config.exs:
config :x_component,
  json_library: Jason,
  root_module: AppWeb.Components,
  root_path: "lib/app_web/components"
  • Disable html format_encoders in configs.exs:
config :phoenix, :format_encoders, html: false
  • Create application layout module:
defmodule MyApp.Components.Layouts.App do
  use Uncovered.Web, :component

  def render(_, assigns) do
    ~X"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        ...
      </head>
      <body>
        <X
          :assigns="@assigns"
          :component="@component"
        />
      </body>
    </html>
    """
  end
end
  • Set layout (in the router.ex or in the controller):
  pipeline :browser do
    plug :put_layout, {MyApp.Components.Layouts.App, :default}
    ...
  end
  • Add use Phoenix.Controller.Components to your controller or to all controllers via the macro in my_app_web.ex:
  def controller do
    quote do
      use Phoenix.Controller, namespace: Uncovered
      use Phoenix.Controller.Components
      ...
    end
  end
  • Specify components root module for the controller (optional):
defmodule MyAppWeb.HomeController do
  use MyAppWeb, :controller

  plug :put_components_module, MyApp.Components.Root
  • Specify page components for the controller action (optional):
defmodule MyApp.ChatController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    conn
    |> put_component(MyApp.Components.Chat)
    |> render()
  end
end

put_components_module and put_component are optional because Phoenix.Controller.Components uses controller and action names to find a component:

MyAppWeb.UserController.show => MyAppWeb.Components.Users.Show
MyAppWeb.HomeController.index => MyAppWeb.Components.Homes.Index

Performance and Benchmarks

Rendering

X templates HTML rendering shows slightly better results than EEx with Phoenix.HTML.Engine. It was achieved due to safe/unsafe interpolation syntax (instead of {:safe, ...} tuples) and due to more compact HTML output with trimmed whitespaces (example here). However, X templates show a significantly faster rendering of nested components (templates in case of EEx) due to the inline components compilation:

Comparison:
X inline (iodata)          20.38 K
X inline (string)          14.48 K - 1.41x slower +19.99 μs
Phoenix EEx (iodata)        7.52 K - 2.71x slower +83.99 μs
Phoenix EEx (string)        6.43 K - 3.17x slower +106.39 μs

Compilation

X templates compile ~2 times slower than EEx templates because it requires to parse the whole HTML into the template AST (see X.Ast) and compile it back to Elixir AST. However, X templates are much faster than other Elixir HTML template implementations:

Comparison:
Floki/Mochi (html parser)        385.79
X (parser)                       357.78 - 1.08x slower +0.20 ms
EEx (html)                       314.95 - 1.22x slower +0.58 ms
X (compiler)                     152.93 - 2.52x slower +3.95 ms
Calliope (haml)                   23.83 - 16.19x slower +39.37 ms
Slime (slim)                       2.27 - 170.23x slower +438.65 ms
Expug (pug)                      0.0836 - 4614.95x slower +11959.75 ms

See all benchmarks here.

TODO

  • [ ] Live view integration
  • [ ] Components cache
  • [ ] Syntax highlight plugins

Vim hack

Syntax highlight via Vue plugin can be enabled by adding the following line to the vim-elixir/syntax/elixir.vim:

syntax include @VUE syntax/vue.vim
syntax region elixirXTemplateSigil matchgroup=elixirSigilDelimiter keepend start=+\~X\z("""\)+ end=+^\s*\z1+ skip=+\\"+ contains=@VUE fold

Issue/Pull Request?

Yes/Please

License

MIT