Xenia Xenia

Keep Phoenix in charge. Let JavaScript do what it does best.
Xenia is the small, simple adapter between the two.

Xenia brings JavaScript libraries (charts, code editors, maps, terminals) into Phoenix LiveView, and lets your folders define your pages.

Contents

What you get

JavaScript in LiveView

  • Any npm library with one small adapter: how it starts, updates and stops.
  • 16 official adapters: Monaco, Tiptap, Chart.js, D3, Tabulator, FullCalendar, Leaflet, xterm.js, flatpickr, Tom Select, Uppy, wavesurfer.js and more.
  • Props from your assigns, events to handle_event, replies for search boxes, and commands from the server to one component.
  • Form fields, app-wide values (theme, locale), keyboard shortcuts, and loading and error states, without adapter code.

Pages from folders

  • File-based routing: page.ex makes a route, [id] is a parameter.
  • Nested layouts, parts that stay alive between pages, and pages that share one LiveView.
  • Page titles, social-card tags and a sitemap.

Everyday work

  • mix xenia.install sets up a new Phoenix app; generators write pages, layouts and adapters.
  • A runnable examples app, and four guides. MIT licensed.

Why Xenia

Xenia (ξενία) is the ancient Greek bond between a host and a guest. Phoenix is the host: it renders the pages, owns the state and stays in charge. A JavaScript library is the guest: it gets a place of its own on the page and does its work there. Xenia is the agreement between them, written once.

The JavaScript community has an excellent library for almost anything a browser can do. With Xenia you use them without hand-writing a LiveView hook for each one: Monaco took a 58-line hook by hand, and is 21 lines as an adapter.

How it works

your LiveView (Elixir)              Xenia                  your adapter (JavaScript)
<.chart data={@sales}      ──►  sends changed props  ──►  props: { data(chart, data) }
        on_click="clicked" />
def handle_event("clicked") ◄──  delivers the event   ◄──  ctx.emit("click", payload)
Xenia.push_to(socket,      ──►  routes it to one     ──►  commands: { highlight(chart, args) }
  "sales", "highlight")         component

Xenia also handles cleanup on navigation, libraries that load slowly, and events sent just before the user leaves a page.

Start a new app

mix phx.new my_app && cd my_app
# add {:xenia, "~> 0.1.0"} to the deps in mix.exs
mix deps.get
mix xenia.install
mix phx.server

mix xenia.install adds a light and dark theme, a layout and home page in the app folder, Xenia's hooks, and flash messages that stack and close by themselves. It prints each change, and leaves alone any file you've already changed.

Quickstart

A Chart.js chart in an existing Phoenix 1.8 app.

1. Add Xenia to mix.exs ({:xenia, "~> 0.1.0"}), run mix deps.get, and install the library: npm install chart.js --prefix assets.

2. Write the adapter, assets/js/chart_adapter.js:

import Chart from "chart.js/auto"

export const chart = {
  mount(el, props, ctx) {
    const canvas = el.appendChild(document.createElement("canvas"))
    return new Chart(canvas, {
      type: props.type,
      data: props.data,
      options: {
        onClick: (_e, points, chart) =>
          points[0] && ctx.emit("click", { label: chart.data.labels[points[0].index] }),
      },
    })
  },
  destroy(chart) { chart.destroy() },
  props: {
    data(chart, data) { chart.data = data; chart.update() },
    type(_chart, _type, ctx) { ctx.remount() }, // Chart.js can't change type in place
  },
  events: { click: {} },
}

3. Register it in assets/js/app.js:

import {createHooks} from "xenia"
import {chart} from "./chart_adapter"

// in the LiveSocket options:
hooks: {...colocatedHooks, ...createHooks({Chart: chart})},

4. Declare the tag, lib/my_app_web/components/charts.ex, then add import MyAppWeb.Charts to html_helpers in lib/my_app_web.ex, and :xenia to import_deps in .formatter.exs:

defmodule MyAppWeb.Charts do
  use Xenia.Components

  component :chart, adapter: "Chart" do
    prop :type, :string, required: true
    prop :data, :map, required: true
    event :click
  end
end

5. Use it in a LiveView (live "/sales", SalesLive in your router):

def mount(_params, _session, socket), do: {:ok, assign(socket, sales: sales(), clicked: nil)}

def render(assigns) do
  ~H"""
  <.chart id="sales" type="bar" data={@sales} on_click="bar_clicked" style="height: 300px" />
  <p :if={@clicked}>You clicked {@clicked}</p>
  """
end

def handle_event("bar_clicked", %{"label" => label}, socket),
  do: {:noreply, assign(socket, clicked: label)}

defp sales, do: %{labels: ~w(Jan Feb Mar), datasets: [%{label: "Sales", data: [12, 19, 7]}]}

Clicking a bar reaches handle_event, a new @sales updates the same chart, and HEEx checks the tag's attributes at compile time. Any npm library works the same way.

Writing an adapter

{
  mount(el, props, ctx)                        // create the library in `el`; return it (or a Promise)
  destroy(instance, ctx)                       // clean up, synchronously
  props:    { name(instance, value, ctx) {} }, // apply one changed prop
  events:   { name: { debounce: ms } },        // events it can send
  commands: { name(instance, args, ctx) {} },  // things the server can ask it to do
  consumes: ["theme"],                         // app-wide values, received as props
}

Only mount and destroy are required. A prop's handler runs only when its value changed; a prop without one is assigned to the instance. el is yours; the container around it belongs to LiveView.

ctx
emit(name, payload)send an event; the page names its LiveView event (on_<name>)
call(name, payload)send an event, get a Promise of the server's {:reply, map, socket}
remount()rebuild the instance, for settings a library can't change in place
isAlive()false once destroyed; check it after your own async work
setFormValue(value)write the component's form value (forms)
upload(name, files)send files through a LiveView upload (files)
id, hookthe component's id; the raw LiveView hook

Commands reach one component: Xenia.push_to(socket, "sales", "highlight", %{index: 2}). Without a declared tag, render the container yourself: <.xenia id="sales" adapter="Chart" props={%{data: @sales}} events={%{click: "bar_clicked"}} />.

The full contract is in What Xenia owns, what your adapter owns.

Official adapters

Install the library in your app and pass it in. Xenia never adds a JavaScript dependency you don't use.

LibraryImport fromTag
Monaco, code and diff editorsxenia/monaco, xenia/monaco-diff<.monaco>, <.monaco_diff>
Tiptap, rich textxenia/tiptap<.tiptap>
Chart.js, D3xenia/chartjs, xenia/d3<.chartjs>, <.d3_chart>
Tabulator, data tablesxenia/tabulator<.tabulator>
FullCalendarxenia/fullcalendar<.full_calendar>
Leaflet, mapsxenia/leaflet<.leaflet_map>
xterm.js, terminalsxenia/xterm<.xterm>
flatpickr, Tom Select, Shoelace ratingxenia/flatpickr, xenia/tom-select, xenia/shoelace-rating<.flatpickr>, <.tom_select>, <.shoelace_rating>
SortableJS, drag and dropxenia/sortablejs<.sortable_list>
Uppy, file uploadsxenia/uppy<.uppy>
wavesurfer.js, audioxenia/wavesurfer, xenia/wavesurfer-multitrack<.wavesurfer>, <.wavesurfer_multitrack>

They all work the same way:

// assets/js/app.js
import {createChartjs} from "xenia/chartjs"

const chartjs = createChartjs({load: () => import("chart.js/auto")})
// hooks: {...colocatedHooks, ...createHooks({ChartJS: chartjs})}
<.chartjs id="sales" type="bar" data={@sales} on_point_click="clicked" />

Configure one with options (createChartjs({load, plugins, options})), and extend it by spreading: {...chartjs, commands: {...chartjs.commands, mine() {}}}. Each tag module (Xenia.Adapters.Tiptap, ...) lists its npm packages, load line, CSS, props, events and commands. Monaco needs a few build steps for its workers: see Xenia.Adapters.Monaco.

More features

Forms

A component with field={@form[:code]} is a form field: submitted with the form, part of phx-change, restored after a reconnect, with errors like <.input>. The official Monaco, Tiptap, flatpickr, Tom Select and Shoelace rating adapters handle it; your own adapter calls ctx.setFormValue(value).

Web components

A custom element needs no prop handlers: each prop is set as a property.

Files

ctx.upload("recording", [file]) sends files a component produced through a LiveView upload (allow_upload and <.live_file_input> on the page).

App-wide values

Publish a value once, and every adapter with consumes: ["theme"] gets it as a prop: from the browser with setProvider("theme", value), or from the server with <Xenia.Provider.provider name="locale" value={@locale} />.

Keyboard shortcuts

shortcuts={%{"mod+s" => "save"}} sends a LiveView event while the component has focus. mod is Cmd on a Mac and Ctrl elsewhere; browser shortcuts are refused.

Loading and errors

<.monaco id="editor" value={@code} on_crash="editor_crashed">
  <:loading>Loading the editor…</:loading>
  <:error>It broke. <button type="button" data-xenia-retry>Try again</button></:error>
</.monaco>

Adapters next to the page

An adapter only one page uses can live in that page's module, in a LiveView colocated hook (adapter={colocated(".Chart")}), with no change to app.js. See Wrapping a library.

Pages from folders

Optional: your folders define your routes, like the Next.js App Router.

# lib/my_app_web/router.ex
import Xenia.Routes

# inside your browser scope:
live_session :app do
  xenia_routes "lib/my_app_web/app"
end
lib/my_app_web/app/
  layout.ex                   frames every page
  page.ex                     /
  posts/[slug]/page.ex        /posts/:slug
  (auth)/layout.ex            a sign-in check for the pages inside
  (auth)/persistent.ex        a sidebar that stays alive between them
  (auth)/dashboard/page.ex    /dashboard
  settings/live.ex            one LiveView for the pages below
  _components/card.ex         never a route
  • page.ex is a LiveView with use Xenia.Page; its folder is its URL.
  • [slug] is a parameter, [...path] the rest of the URL, (group) stays out of the URL, _private is never a route.
  • layout.ex frames the pages below it; its on_mount: protects them.
  • persistent.ex keeps its state while users move between those pages.
  • metadata/1 sets titles and social tags; Xenia.Sitemap serves a sitemap.

It compiles to ordinary live routes: mix phx.routes and ~p work as usual. The guide: Building pages from folders.

Generators

mix xenia.gen.component chart data:map --events click   # an adapter and its tag
mix xenia.gen.page reports/sales                        # a page.ex
mix xenia.gen.layout "(auth)" --persistent              # a layout.ex and persistent.ex

Configuration and troubleshooting

  • Custom binding prefix: if your LiveSocket uses one, set the same: config :xenia, binding_prefix: "pop-".
  • "Could not resolve "xenia"": run mix deps.get. For a path dependency, add the folder that contains the xenia checkout to NODE_PATH in your esbuild config.
  • Changes to a path dependency don't show up: mix deps.clean xenia --build, then restart mix phx.server.

Examples and guides

Developing Xenia

lib/xenia/core/        the JS-island boundary: <.xenia>, the DSL, providers, shortcuts
lib/xenia/app/         pages from folders: routes, pages, layouts, metadata, sitemap
lib/xenia/adapters/    the official adapters' tags
lib/mix/               mix xenia.install, xenia.routes and the generators
assets/                the JavaScript: core/ and adapters/, prebuilt into priv/static
test/                  the same layout, plus test/js
examples/              the examples app
mix setup   # dependencies, esbuild and the bundles
mix check   # format, compile, bundles, tests, the Hex package and the docs

Commit the rebuilt priv/static with your change; CI checks it matches the sources.

Contact

Bugs, questions and ideas: GitHub issues. Anything else, including security problems you'd rather not post publicly: uzaydurdu.com.

License

MIT. Free to use in open-source and commercial projects.