Orchard Lab

A place to share my thoughts and learnings

Use Svelte in Phoenix the simple way

Posted at — Mar 8, 2022

TLDR

Gist link for the changes to make this happen

Why

While there were few options to integrate Svelte into existing Phoenix app. But I still don’t find any of them to be particular simple. What I really want is something that I can understand, as minimal as possible, no heavy Node tooling involved. After play with different options, I finally found a sweet spot for me to use Svelte while not overwhelmed by the additional toolings. In fact, it’s just couple lines of configuration with two files both less than 20 lines of code.

Before diving into my solution to this. Let’s first understand how Phoenix integrate with JavaScript/CSS assets by default.

Turn Svelte components into JS

Since 1.6, Phoenix uses esbuild to build your JavaScripts. The esbuild is a standalone Go program thus no Node, NPM invovled. That’s why in the assets folder, there is neither package.json nor node_modules . The esbuild will slurp the entry app.js then output the results into priv/static/assets.

So the first thought I have is how can I not step away from the default path, still using esbuild for Svelte. The good news is that there is a esbuild-svelte plugin, the bad news is that I have to invoke the plugin asynchronously, in other words, by writing a JS build file.

Install packages:

cd assets && npm install esbuild esbuild-svelte svelte svelte-preprocess --save-dev

Luckly, esbuild-svelte already handled the heavy lifting part. So the build.js is actually quite simple:

// build.mjs
import esbuild from "esbuild";
import esbuildSvelte from "esbuild-svelte";
import sveltePreprocess from "svelte-preprocess";
import { readdir } from "fs/promises";

async function allSvelteComponents() {
  const baseDir = "./js/app/";
  const all = await readdir(baseDir);
  return all.filter((f) => f.endsWith(".svelte")).map((f) => `${baseDir}${f}`);
}

(async function () {
  const components = await allSvelteComponents();
  const isProd = process.env.NODE_ENV == "production";
  esbuild
    .build({
      entryPoints: components,
      format: "esm",
      bundle: true,
      minify: isProd,
      watch: !isProd,
      outdir: "../priv/static/assets/svelte",
      plugins: [
        esbuildSvelte({
          preprocess: sveltePreprocess(),
        }),
      ],
    })
    .catch(() => process.exit(1));
})();

Using .mjs as extension here to indicate this is a ES module. So that I don’t need to change the package.json’s type globally.

Now if you manually do node ./assets/build.mjs you should be able to see the built components JS in the priv/static/assets/svelte folder.

Let’s add this build step into both dev and deployment time:

# mix.exs
 defp aliases do
    [
      "assets.deploy": [
        ...
        # build svelte components
        "cmd --cd assets NODE_ENV=production node ./build.mjs"
      ]
    ]
  end
# dev.exs
watchers: [
    ...
    # build and watch svelte components
    node: ["build.mjs", "--watch", cd: Path.expand("../assets", __DIR__)]
  ]

This is it.

Consuming it through <script type="module">

<script type="module"> is such a wonderful thing. It finally makes consuming JavaScript so much easier and nicer.

Now all you need to do to render a Svelte component is put this in your .heex template:

<div id="hello"></div>
<script type="module">
    import Hello from "<%= Routes.static_path(@conn, "/assets/svelte/Hello.js") %>"
    new Hello({
        target: document.getElementById("#hello"),
        props: { name: "Mike" }
    })

This is it.

Render it the Elixir way

To push it a bit further. You can write such a helper to make the above rendering even simpler.

defmodule EasySvelteExampleWeb.SvelteView do
  use EasySvelteExample, :view

  # just for default values
  def render_component(conn, name, props \\ [])

  def render_component(conn, name, props) when is_list(props) do
    render_component(conn, name, Enum.into(props, %{}))
  end

  def render_component(conn, name, props) when is_map(props) do
    component_id = "svelte-ele-#{name}-#{random_id()}"
    component_import_path = Routes.static_path(conn, "/assets/svelte/#{name}.js")
    props_json = props |> Jason.encode!()
    component_class_name = name |> String.split("-") |> Enum.map_join(&String.capitalize(&1))

    [
      content_tag(:div, "", id: component_id),
      content_tag :script, type: "module" do
        """
        import #{component_class_name} from "#{component_import_path}"
        new #{component_class_name}({
            target: document.getElementById("#{component_id}"),
            props: #{props_json}
        })
        """
        |> raw
      end
    ]
  end

  defp random_id do
    for _ <- 1..10, into: "", do: <<Enum.random('0123456789abcdef')>>
  end
end

Now to render a svelte component in .heex:

<%= SvelteView.render_component("Hello", name: "Mike") %>

Here you can find the gist link of all the changes needed for this.