Component-based HTML templates for Elixir/Phoenix, inspired by Vue.
Zero-dependency. Framework/library agnostic. Optimized for Phoenix and Gettext.
def deps do
[
{:x_component, "~> 0.1.0"}
]
end
↳ 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.
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>
"""
<div>
<meta item="example">
<span />
</div>
<X :is="tag_name" />
<div>{{ message }}</div>
<div>{{= html_string }}</div>
<button class="d-flex" data-item="1" />
<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}>
x-for
is compiled into Elixir for
list comprehensions.
<ul>
<li x-for="i <- [1, 2, 3, 4], i > 2">{{ i }}</li>
</ul>
<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>
<!-- Example -->
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>
Component can be rendered dynamicaly from Elixir expresion using special X
tag with :component
attribute:
<X :component="component_module" />
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.
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
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>
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
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
"""
- Remove
:phoenix_html
library (optional). - Add
:x_component
application configs to theconfig/config.exs
:
config :x_component,
json_library: Jason,
root_module: AppWeb.Components,
root_path: "lib/app_web/components"
- Disable html
format_encoders
inconfigs.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 inmy_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
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
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.
- Live view integration
- Components cache
- Syntax highlight plugins
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
Yes/Please
MIT