Gist link for the changes to make this happen
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.
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.
<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.
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.