Svx View Source

A PoC for single-file components for Phoenix LiveView

Table of Contents

Installation

  1. Add svx to your list of dependencies in mix.exs:
def deps do
  [
    {:svx, "~> 0.3.2"}
  ]
end

Note: Requires fswatch (apt-get fswatch or brew install fswatch)

  1. In lib/<you_app>/application.ex add Svx to apps that you start:
{Svx.Compiler, [path: "lib/<your_app>_web/live", namespace: ExampleWeb.Live]}
  1. Add @import "./generated.css"; to assets/css/app.css
  2. Create your views as .lsvx in lib/your_app_web/live

They will be available under ExampleWeb.Live.

How to

See also priv/example for a full example

Component structure

An svx-component is a file with a .lsvx extension that contains three parts (the order in which they appear in the file is not important):

  • <script lang="elixir">... code ...</script> that contains your module's Elixir code. The mount function goes in here, as any other function you would write in your view.ex

  • <style>... css ... </style>. Regular css. CSS from each svx-component will be extracted and placed in a single file at asstes/css/generated.css. No, the CSS isn't scoped, it requires far more work than is feasible for a PoC

  • regular HTML/HEex. Everything else in the file is assumed to be regular HTML/Heex and will form the basis of the render/1 function

Module names

Module names are generated by a simple substitution:

  • take path relative to lib
  • remove all underscores (_)
  • Title Case everything
  • join with periods (.)

So, your_app/lib/your_app_web/live/ui/some_module.lsvx becomes YourAppWeb.Live.Ui.SomeModule

When you set up your router.ex, you can *Web:

  scope "/", SvxWeb do
    pipe_through :browser

    get "/", PageController, :index
    live "/thermostat", Live.Thermostat
  end

Svx compiler will output component names to stdout, so you you can see what names are actually generated

Generated CSS

All code in <style></style> is extracted and placed at assets/css/generated.css. The easiest way to make sure that it's reloaded when you change it is to add @import "./generated.css"; to assets/css/app.css

Errors

If you have errors in your markup, Svx will still attempt to compile your component, but will replace component content with the error from Heex tokenizer.

Example

  • Place component code below at lib/your_app_web/live/thermostat.lsvx

  • In your router.ex add

    scope "/", YourAppWeb do
      pipe_through :browser
    
      get "/", PageController, :index
      live "/thermostat", Live.Thermostat
    end
  • Add @import "./generated.css"; to assets/css/app.css

  • Run your Phoenix app with iex -S mix phx.server, and navigate to http://localhost:4000/thermostat

  • Change Elixir code, HTML, styles, and see them update in the browser

Component code

<script type="elixir">
  use ExampleWeb, :live_view

  def mount(_params, _p, socket) do
    temperature = 11
    {:ok, assign(socket, :temperature, temperature)}
  end
</script>

<%= for x <- [1,2,3], do: "#{x}" %>

<div title={@temperature}>
  <p class={"temp-#{@temperature > 10}"}>Hello, temperature is: <%= @temperature %></p>
</div>

<style>
  .temp-false {
    color: blue;
    font-size: 24pt;
    text-decoration: underline;
  }
  .temp-true {
    color: red;
    font-size: 24pt;
    text-decoration: underline;
  }
</style>

The code above is equivalent to

defmodule YourAppWeb.Live.Thermostat do
  use ExampleWeb, :live_view

  def mount(_params, _p, socket) do
    temperature = 11
    {:ok, assign(socket, :temperature, temperature)}
  end

  def render(assigns) do
    ~H"""
    <%= for x <- [1,2,3], do: "#{x}" %>

    <div title={@temperature}>
      <p class={"temp-#{@temperature > 10}"}>Hello, temperature is: <%= @temperature %></p>
    </div>
    """
  end
end

And the css will be located at assets/css/generated.css

Caveats

It's a proof of concept. So things will definitely break :)

The code uses LiveView's Phoenix.LiveView.HTMLTokenizer.tokenize/5 directly:

  • If that API changes, is removed or becomes private, svx breaks

  • This API isn't aware of Eex constructs, so the code does some string replacement:

    • replace Eex-like tokens and Elixir-like tokens inside Eex with placeholders
    • tokenize
    • replace placeholders back

    I didnt' do any exhaustive checking on this, so there will d,efinitely be some constructs that break

Additionally, all I do is create a string with module code, and run Code.compile_string/2 on it. So this can break :)

Also: no tests. Of course. It's a PoC :D

Motivation

I really like Svelte's single file components and wished I had something similar for LiveView:

  • Templating code isn't split into a separate file
  • Templating code isn't in a string
  • Styling code isn't in a separate file in an entirely different directory

IMO the sweet spot for single-file components is a medium-to-large template with not too-much elixir code powering it.