Lissome is a library to integrate the Gleam frontend framework Lustre with Phoenix Live View.
Warning
This project is on early stage of development and breaking changes are expected.
First, make sure you have the Gleam compiler installed. Instructions can be found here
- Add
lissome
andlustre
to yourmix.exs
file:
def deps do
[
...,
{:lissome, "~> 0.3.1"},
{:lustre, "~> 4.6.3", app: false, manager: :rebar3}
]
end
then run:
mix deps.get
- Create a new Gleam project and add Lustre to it. You can create it anywhere, but it is recommended to create it inside the
assets
directory. By default, Lissome will search for a Gleam project in theassets/lustre_app
directory. If you chose a different name, set the path where you Gleam project lives in thegleam_dir
config:
gleam new my_gleam_app
cd my_gleam_app
gleam add lustre
# config/config.exs
# if you created your Gleam project in a different location than "assets/lustre_app"
config :lissome, :gleam_dir: "my_dir/my_gleam_app"
Lissome ships with its own gleam package that contains utilities functions to interop with Phoenix LiveView. You can add it to your Gleam project as a path dependency:
# gleam.toml
[dependencies]
lissome = { path = "path/to/deps/lissome/src_gleam" }
- Register a hook with the name
LissomeHook
in yourLiveSocket
instance using thecreateLissomeHook
function from Lissome. This function takes an object containing the Gleam modules you want to render as an argument. The keys must be the name of the modules, in lowercase, and the values the functions responsables to start the Lustre app in each of those modules (this is typically themain
function).
// app.js
import { createLissomeHook } from "path/to/deps/lissome/assets/lissome.mjs"
import { main as hello_main } from "path/to/my_gleam_project/build/dev/javascript/my_gleam_app/hello.mjs"
import { main as about_main } from "path/to/my_gleam_project/build/dev/javascript/my_gleam_app/pages/about.mjs"
const lustreModules = { hello: hello_main, about: about_main }
let liveSocket = new LiveSocket("/live", Socket, {
...,
hooks: { ..., LissomeHook: createLissomeHook(lustreModules) },
});
- Add the following watcher to your list of watchers in the
config/dev.exs
file and pass it the optionwatch: true
to enable live reloading inside your Gleam project during development:
# config/dev.exs
config :my_app, MyAppWeb.Endpoint,
...,
watchers: [
...,
gleam: {Lissome.GleamBuilder, :build_gleam, [:javascript, [watch: true]]}
]
- Update your esbuild config to use the
es2020
target:
# config.exs
config :esbuild,
...,
my_app: [
args:
~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
...
]
- If you plan to use Tailwind CSS inside your Gleam code, add your gleam files to the content key in the Tailwind CSS config.
// tailwind.config.js
module.exports = {
content: [
...,
"../path/to/my_gleam_app/src/**/*.gleam",
],
}
To render a Lustre app, define a Gleam module with a public function that initializes the app using either the lustre.simple
or lustre.application
constructor.
//// src/hello.gleam
pub fn init() {
//...
}
pub fn update(msg, model) {
//...
}
pub fn view(model) {
//...
}
pub fn main() {
let app = lustre.simple(init, update, view)
let assert Ok(_) = lustre.start(app, "#app", Nil)
Nil
}
Now, inside HEEX
we can render it using the .lustre
component:
defmodule MyAppWeb.MyLiveView do
use MyAppWeb, :live_view
import Lissome.Component
def mount(_params, _session, socket) do
{:ok, socket}
end
def render(assigns) do
~H"""
<div>
<div>Content rendered with Phoenix Live View</div>
<div>
<.lustre id="app" name={:hello} />
</div>
</div>
"""
end
end
Lissome will encode the flags passed from the server as a json object in a script tag with the id ls-model
. We can retrieve the flags from Gleam using the lissome.get_flags
helper:
import lissome // <- remember to add this to your project as a path dependency
pub fn main() {
let decoder = {
use count <- decode.field("count", decode.int)
use light_on <- decode.field("light_on", decode.bool)
decode.success(Model(count, light_on))
}
let flags = case lissome.get_flags(id: "ls-model", using: decoder) {
Ok(flags) -> flags
Error(_) -> Model(8, True)
}
}
The id of the script tag could be customized by passing to the id
attribute in the .lustre
component the value you want.
Check out the project in the example
directory for a complete code example.
Thanks to the ability of Gleam to compile to both Erlang and JavaScript, we can do server-side rendering of Lustre without having to install Node.js. We only need to make sure we compile the Gleam project to Erlang too. For that, install Lustre in your Elixir project and add the :gleam
compiler to your list of compilers:
# mix.exs
def project do
[
compilers: Mix.compilers() ++ [:gleam]
]
end
then update your watcher in config/dev.exs
to include the :erlang
target:
config :my_app, MyAppWeb.Endpoint,
...,
watchers: [
...,
gleam: {Lissome.GleamBuilder, :build_gleam, [[:javascript, :erlang], [watch: true]]}
]
and if the name of your gleam project is different than "lustre_app"
, register it in the gleam_app
config:
config :lissome, :gleam_app, "my_gleam_app"
Now, pass the ssr={true}
attribute to each .lustre
component you want to render in the server.
Keep in mind that Lissome
will call the init
and the view
functions of your Gleam module in order to render the initial HTML. By default Lissome
will look for functions with that name in your module. If you happen to named them differently, you can pass to the init_fn
attribute the name of your function responsible for initializing the model and to the view_fn
attribute the name of your function responsible for rendering the view. Both functions must be public.
The type your flags has must be public too. Lissome will use this type to construct the appropriate Erlang record. By default, the expected name for that type is Model
. If you have a different name, pass to the flags_type
attribute the name of your type in lowercase.
<.lustre
id="app"
ssr={true}
name={:hello}
init_fn={:my_init_function}
view_fn={:my_view_function}
flags_type={:my_flags_type}
flags={...}
/>
Remember to add to your mix.exs
file any other dependencies your Gleam project needs apart from Lustre and the Gleam standard library. You can add Gleam dependencies to Mix like any other dependency, but with the app: false
and manager: :rebar3
options.
Lissome
includes helpers for communicating with a LiveView running in the server from Gleam code, using Lustre's effects for managed side effects. To enable this bidirectional communication, you need to construct your app with lissome.application
. This is a wrapper around lustre.application
that allows your init
and update
function to receive the LiveView hook instance as an argument. This instance could be used to communicate with the server by passing it to the functions in the lissome/live_view
module.
// other imports
import lissome
import lissome/live_view
type Model {
Model(name: String, email: String)
}
type Msg {
ServerUpdatedName(String)
UserUpdatedEmail(String)
ServerReply(live_view.LiveViewPushResponse)
}
pub fn init(flags, lv_hook: lissome.LiveViewHook) {
let eff = live_view.handle_event(
lv_hook: lv_hook,
event: "send-name",
on_reply: ServerUpdatedName
)
#(flags, eff)
}
pub fn update(model, msg, lv_hook: lissome.LiveViewHook) {
case msg {
ServerUpdatedName(name) -> #(Model(..model, name:), effect.none())
UserUpdatedEmail(email) -> {
let eff = live_view.push_event(
lv_hook: lv_hook,
event: "update-email",
payload: email,
on_reply: ServerReply
)
#(Model(..model, email:), eff)
}
ServerReply(live_view.LiveViewPushResponse(_reply, _ref)) -> #(
model,
effect.none(),
)
}
}
pub fn view(model) {
//...
}
pub fn main(hook: lissome.LiveViewHook) {
let flags = Model("John", "[email protected]")
let app = lissome.application(init, update, view, hook)
let assert Ok(_) = lustre.start(app, "#app", flags)
Nil
}
Note that the hook
instance is passed to your entry function as its only argument. This is done by Lissome when rendering the module.
When doing SSR, Lissome needs to initialize your app's model by passing the flags as arguments. Since Gleam types are compiled to Erlang tuples, there is a challenge when passing non-primitive types as flags from Elixir because Erlang tuples are not easy serializable to JSON.
To address this, Lissome provides the Lissome.GleamType
module. This module defines a struct that represent a Gleam type. During SSR, Lissome automatically detects these structs in the flags and converts them to the correct Erlang terms. This conversion happens recursively for any nested Lissome.GleamType
structs. The Lissome.GleamType
struct can also be serialized to JSON.
Gleam compiles types constructors that do not have specific fields, like Ok(a)
, Error(a)
or Some(a)
, to a tuple, where the first value is the name of the type constructor as an atom and the second one the value. To represent that kind of types, we can use the Lissome.GleamType.from_value/2
function:
<.lustre
module={:hello}
ssr={true}
flags={%{name: Lissome.GleamType.from_value(:some, "Jhon")}}
>
When a type has multiple fields, Gleam compiles it to an Erlang record. An Erlang record is a tuple where the first element is the name of the type constructor as an atom and the rest elements are the values. Erlang expects the values are in a specific order and includes that information in a .hrl
file. To represent those types we can use the Lissome.GleamType.from_record/4
function:
<.lustre
module={:hello}
ssr={true}
flags={%{person: Lissome.GleamType.from_record(
:person,
:hello,
%{name: "Jhon", age: 30}
)}}
>
The first argument is the name of the type constructor in lowercase, the second one is the name of the module where that type is defined and the third one is a map with all the values we want to pass to the type constructor.
When the Lissome.GleamType
struct is encoded to JSON, we get:
- The raw value when the type is not compiled to an Erlang record.
- A map with all types fields and its values when the type is compiled to an Erlang record.
See the Lissome.GleamType
module in the documentation for more details.
- Improvements to the SSR.
- Gleam's helpers for communicating with Phoenix LiveView and supporting Lustre's effects.
- Live reload for Gleam.
- Helpers to work with Gleam types and their Erlang representation in Elixir.
- Support for
lustre.component
constructor. - Support for Lustre's
server components
. - Support for
LiveJson
.