Xenia

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

Xenia (ZEE-nee-uh) brings JavaScript libraries into Phoenix LiveView: charts, code editors, maps, terminals, date pickers, players. You write one small adapter that says how a library starts, updates and stops. Xenia handles everything between your LiveView and the browser.

What you get

JavaScript in your LiveView, the easy way

  • Any npm library, one small adapter. mount, destroy, and code only for the props that need it. No fork, and no waiting for official support.
  • Props, events, calls and commands. Your assigns flow in as props, and only the ones that changed reach the library. Its events arrive in handle_event, and a call gets the reply back, for search boxes and completions. Xenia.push_to/4 tells one component to act.
  • Typed HEEx tags. component :chart, adapter: "Chart" gives you <.chart>, checked at compile time like any function component.
  • Form fields. <.date_picker field={@form[:due]} /> is submitted with the form, runs phx-change, comes back after a reconnect, and shows errors like <.input>.
  • App-wide values. Publish a value once, from the server or the browser: the theme, the locale, the time zone, a feature flag, a user preference. Every component that asks for it gets it as an ordinary prop, and gets every change.
  • Keyboard shortcuts. shortcuts={%{"mod+s" => "save"}} sends a LiveView event while the component has focus.
  • Loading and error states. <:loading> shows while a big library downloads; <:error> shows if it breaks, with a "Try again" button and an on_crash event for the server. No adapter code.
  • JavaScript next to the page. An adapter only one page uses can live in that page's module, with no change to app.js.
  • Sixteen official adapters you configure and extend. Code and diff editors (Monaco), rich text (Tiptap), charts (Chart.js, D3), data tables (Tabulator), calendars, maps, terminals, date pickers, tag pickers, sortable lists, file uploads and audio. Install the library, pass it in, and add your own commands or events without copying a line of the adapter.

Pages from folders

  • File-based routing. page.ex makes a route, [id] is a parameter, and (group) folders stay out of the URL, like the Next.js App Router. Xenia generates plain Phoenix live routes, so ~p and mix phx.routes work as usual.
  • Nested layouts. A layout.ex frames every page in its folder and below.
  • Parts that stay alive. A persistent.ex keeps running while the user moves between pages: a sidebar keeps its state, an open editor keeps its text and undo history.
  • Pages that share state. A live.ex gives several pages, like the tabs of a settings screen, one LiveView.
  • Titles, social cards and a sitemap. metadata/1 on pages and layouts fills <title>, the description and Open Graph tags; Xenia.Sitemap lists your pages for search engines.

Made for everyday work

  • A clean start. mix xenia.install turns a new Phoenix app into a Xenia app: a plain light and dark theme in Xenia's colors, a layout, a home page from the app folder, and the hooks already wired.
  • Generators: mix xenia.gen.component, mix xenia.gen.page, mix xenia.gen.layout.
  • Clear errors that name the fix: an unregistered adapter, two pages with one URL, a shortcut that belongs to the browser.
  • Icons, notifications and languages, with Phoenix's own tools: Iconify icon sets as Tailwind classes, success and error messages with put_flash/3, and Gettext translations that reach your JavaScript islands as props. See the guide and the examples.
  • A runnable examples app, one page per feature, each with a short README.
  • Free and open source, under the MIT license.

Why Xenia

The JavaScript community is huge, and it has already built an excellent library for almost anything a browser can do. Phoenix developers shouldn't have to rebuild that work, or hand-wire a LiveView hook for every library.

I built Xenia to make bringing JavaScript into Phoenix simple. Your app stays server-rendered Phoenix, with your state on the server. The few parts that need real browser JavaScript, which Xenia calls JS islands, get a clear and tested home. The JavaScript library keeps doing its browser work; Xenia standardizes how it talks to your LiveView and when it starts and stops. You don't learn a new framework: Xenia extends LiveView, and you keep using the npm packages you already know.

How much it saves, measured on a real integration: the Monaco editor took a 58-line hand-written hook. As a Xenia adapter it is 21 lines plus one line to register it, and it can also take commands from the server.

Xenia (ξενία) is the ancient Greek bond between a host and a guest. The host gives shelter, the guest respects the house, and both know their part.

That's how Xenia works. Phoenix is the host: it renders the page, owns the state and stays in charge. A JavaScript library is the guest: it gets an element of its own, and does its work there. Xenia is the agreement between them, written down and kept.

The logo tells the same story: a purple Elixir crystal and a golden JavaScript bolt, fitted together into one shape.

How it works

A JS island has three parts. You write the first two; Xenia is the third:

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
  • Props flow from your assigns to the library. Only the ones that changed are sent.
  • Events flow from the library to your handle_event.
  • Commands let the server tell one component to do something.

Xenia also takes care of what's easy to get wrong in a hand-written hook: cleaning up when the user navigates away, a library that loads slowly, events sent just before a navigation, form fields, and the theme.

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 replaces Phoenix's welcome page with Xenia's starter:

  • a plain light and dark theme in Xenia's purple and gold, with a faint grid at the top of the page. Phoenix's theme toggle switches it.
  • a layout (header, theme toggle, footer) and a home page, in lib/my_app_web/app/, where folders are routes
  • Xenia's hooks in app.js, with the theme published to the components that follow it
  • better flash messages: a success kind next to info and error, stacked newest first, and closing by themselves after 5 seconds with a countdown line around their border (errors stay until dismissed)

Each change is printed. A file you already changed is left alone, and the step is printed for you to do by hand. Running it again changes nothing.

To add Xenia to an app you already have, follow the quickstart below.

Quickstart: a Chart.js chart in a Phoenix app

About ten minutes, in an app made with mix phx.new (Phoenix 1.8, LiveView 1.2). MyApp/my_app stand for your app's name. If you ran mix xenia.install, step 4's createHooks is already in app.js: add your adapter to it.

1. Add Xenia to mix.exs and run mix deps.get:

{:xenia, "~> 0.1.0"}

2. Install the library in your app's assets:

npm install chart.js@4.5.1 --save-exact --prefix assets

3. Write an adapter, assets/js/chart_adapter.js. It only says how the library is created, updated and destroyed:

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: {} },
}

4. Register it in assets/js/app.js. Add Xenia's hooks to the ones already there; keep the rest of the options as Phoenix generated them:

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

const liveSocket = new LiveSocket("/live", Socket, {
  longPollFallbackMs: 2500,
  params: {_csrf_token: csrfToken},
  hooks: {...colocatedHooks, ...createHooks({Chart: chart})},
})

5. Declare the tag, lib/my_app_web/components/charts.ex:

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

Then make it available in every template: in lib/my_app_web.ex, inside html_helpers, add import MyAppWeb.Charts. In .formatter.exs, add :xenia to import_deps, so mix format keeps the declarations without parentheses.

6. Use it in a LiveView, lib/my_app_web/live/sales_live.ex, with live "/sales", SalesLive in your router:

defmodule MyAppWeb.SalesLive do
  use MyAppWeb, :live_view

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

  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash}>
      <button phx-click="refresh" class="btn btn-sm">New data</button>
      <.chart id="sales" type="bar" data={@sales} on_click="bar_clicked" style="height: 300px" />
      <p :if={@clicked}>You clicked {@clicked}</p>
    </Layouts.app>
    """
  end

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

  def handle_event("refresh", _params, socket),
    do: {:noreply, assign(socket, sales: sales())}

  defp sales do
    labels = ~w(Jan Feb Mar Apr May Jun)

    %{
      labels: labels,
      datasets: [%{label: "Sales", data: Enum.map(labels, fn _ -> Enum.random(10..100) end)}]
    }
  end
end

With the app folder (Pages from folders, set up by mix xenia.install), the same LiveView goes in lib/my_app_web/app/sales/page.ex with use Xenia.Page after use MyAppWeb, :live_view. Then you need no live route, and no <Layouts.app> wrapper: the folder is the route, and your layout.ex frames the page.

Run mix phx.server and open /sales:

  • clicking a bar sends bar_clicked to your LiveView
  • "New data" updates the same chart in place
  • the declared tag is an ordinary function component, so HEEx warns at compile time about a missing required prop or a misspelled attribute

A library Xenia has never heard of works the same way. That's the point: your adapter, your library, no change to Xenia.

Next:

The adapter

{
  mount(el, props, ctx)       // create the library inside `el`; return the instance (or a Promise of it)
  destroy(instance, ctx)      // clean up, synchronously
  props:    { name(instance, value, ctx) {} },  // apply one changed prop
  events:   { name: { debounce: ms } },         // events this component 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. Xenia compares the props on every render, and calls a prop's handler only when its value changed. A prop without a handler is assigned to the instance (instance[name] = value), which is all a web component needs.

el is an empty element Xenia creates inside the LiveView container, and it is yours: put the library's DOM there. The container itself belongs to LiveView.

ctx, one per instance:

ctx.emit(name, payload)send an event; the page decides which LiveView event it becomes (on_<name>)
ctx.call(name, payload)send an event and get the server's reply: a Promise of what handle_event returns as {:reply, map, socket}
ctx.remount()rebuild the instance with the latest props, for libraries that can't change a setting in place
ctx.isAlive()false once destroyed; check it after your own async work
ctx.idthe component's id, for naming global resources
ctx.setFormValue(value)see Components in forms
ctx.upload(name, files)see Sending files
ctx.hookthe raw LiveView hook, if you need something Xenia doesn't offer

Events go to the LiveView event named by the on_<name> attribute (on_click="bar_clicked"). Unbound events are dropped. A chatty event can be debounced: events: { change: { debounce: 300 } }. Pending events are still sent when the user navigates away.

Calls are events that get an answer, for libraries that ask for data: a search box asking for matches, an editor asking for completions, a calendar asking for a month of events. Declare and bind them like events; the server replies in the usual Phoenix way:

// In the adapter: Tom Select asks for results, and gets that query's reply.
load: (query, callback) =>
  ctx.call("search", { query }).then((reply) => callback(reply.options), () => callback()),
def handle_event("search_countries", %{"query" => query}, socket) do
  {:reply, %{options: Countries.search(query)}, socket}
end

Each reply belongs to its own call, so a slow answer can't fill in the results of a newer query. Pending events are sent before a call, and calls aren't debounced. A call rejects if the page didn't bind it, or if LiveView can't deliver it; after awaiting one, check ctx.isAlive() before using the reply.

Commands go from the server to one component, by id:

socket |> Xenia.push_to("sales", "highlight", %{index: 2})
commands: {
  highlight(chart, { index }) { chart.setActiveElements([{ datasetIndex: 0, index }]); chart.update() },
}

Without a declared tag, render the container yourself (import Xenia.Component): <.xenia id="sales" adapter="Chart" props={%{type: "bar", data: @sales}} events={%{click: "bar_clicked"}} />. Other attributes (class, style, aria-*, phx-click) pass through to the container. id, phx-hook, phx-update and data-xenia-* belong to Xenia, and passing them raises.

If a component's adapter was never registered, the browser console says so, naming the element and the line to add to createHooks.

An adapter only one page uses

It can live in the page's module, in a LiveView colocated hook, with no change to app.js:

<.xenia id="sales" adapter={colocated(".Chart")} props={%{values: @values}} />

<script :type={Phoenix.LiveView.ColocatedHook} name=".Chart">
  import Chart from "chart.js/auto"
  import { adapterHook } from "xenia"
  export default adapterHook({ mount(el, props) { /* ... */ }, destroy(chart) { chart.destroy() } })
</script>

colocated(".Chart") resolves to this module's hook, so two pages can each have a .Chart. See Wrapping a library.

Keyboard shortcuts

Give any component shortcuts, and the keys send LiveView events while focus is inside it:

<.monaco id="editor" value={@code} shortcuts={%{"mod+s" => "save", "mod+shift+." => "bigger"}} />
def handle_event("save", %{"shortcut" => "mod+s"}, socket), do: ...

mod is Cmd on a Mac and Ctrl elsewhere. The library and the browser don't act on a matched shortcut, and the component's pending events are sent first, so a save right after typing sees the last change. Browser shortcuts such as zoom (mod+=) or closing a tab (mod+w) can't be used: Xenia raises an error. See Xenia.Shortcuts.

Loading and errors

Every tag takes two slots, and neither needs adapter code:

<.monaco id="editor" value={@code} on_crash="editor_crashed" style="height: 400px">
  <:loading>Loading the editor…</:loading>
  <:error>
    The editor couldn't start.
    <button type="button" data-xenia-retry>Try again</button>
  </:error>
</.monaco>
  • <:loading> is rendered by the server, so it shows from the first paint, and stays until the island has loaded. Give the island a height, and the placeholder fills it. The island underneath is still sized while it loads, so libraries that measure themselves work.
  • <:error> shows instead of the island if its adapter throws: while loading, in a prop update, in a command or in a remount.
  • data-xenia-retry on any element inside it starts the island again.
  • on_crash sends a LiveView event with %{"id" => id, "phase" => phase, "message" => message}.

A component removed by a navigation is not a crash.

Official adapters

Xenia ships ready-made adapters for popular libraries. You install the library in your app and pass it in, so Xenia never adds a JavaScript dependency you don't use. Each one has its own entry point and its own tag:

LibraryImport fromTagForm field
Monaco (code editor)xenia/monaco<.monaco>yes
Monaco (diff editor)xenia/monaco-diff<.monaco_diff>yes
Tiptap (rich text)xenia/tiptap<.tiptap>yes
Chart.jsxenia/chartjs<.chartjs>
D3 (bar, line, pie, your own)xenia/d3<.d3_chart>
Tabulator (data table)xenia/tabulator<.tabulator>
FullCalendarxenia/fullcalendar<.full_calendar>
Leaflet (map)xenia/leaflet<.leaflet_map>
xterm.js (terminal)xenia/xterm<.xterm>
flatpickr (date picker)xenia/flatpickr<.flatpickr>yes
Tom Select (select, tags)xenia/tom-select<.tom_select>yes
Shoelace ratingxenia/shoelace-rating<.shoelace_rating>yes
SortableJS (drag and drop)xenia/sortablejs<.sortable_list>
Uppy (file uploads)xenia/uppy<.uppy>
wavesurfer.js (audio)xenia/wavesurfer<.wavesurfer>
wavesurfer multitrackxenia/wavesurfer-multitrack<.wavesurfer_multitrack>

They all work the same way, shown here with Chart.js. Each tag module's docs (Xenia.Adapters.Tiptap, Xenia.Adapters.Tabulator, ...) list the npm packages, the load line, the CSS to import, and every prop, event and command.

Chart.js

npm install chart.js --prefix assets
// assets/js/app.js
import {createChartjs} from "xenia/chartjs"

const chartjs = createChartjs({load: () => import("chart.js/auto")})

hooks: {...colocatedHooks, ...createHooks({ChartJS: chartjs})},

With import Xenia.Adapters.Chartjs in html_helpers:

<.chartjs id="sales" type="bar" data={@sales} on_point_click="clicked" style="height: 300px" />

data and options are Chart.js's own, so every chart type and option works. The chart follows your app's theme. Events: point_click, legend_click, hover, error. Commands: highlight, clear_highlight, toggle_dataset, reset, download_image.

Configure it once for your whole app. Chart.js is loaded once, and each plugin is registered once, however many charts you have:

const chartjs = createChartjs({
  load: () => import("chart.js/auto"),
  plugins: [zoomPlugin],                  // any Chart.js plugin
  options: {animation: false},            // defaults; a chart's own options win
  palette: {light: [...], dark: [...]},   // dataset colors
  setup: (chart) => chart.canvas.setAttribute("aria-label", "Sales chart"),
})

Extend it without copying it. The adapter is a plain object, so spread it and add what you need:

export const salesChart = {
  ...chartjs,
  commands: {
    ...chartjs.commands,
    zoom_out(instance) { instance.chart.resetZoom() },
  },
}

The examples app's Chart.js page does all three, and it has a page for every other official adapter.

Libraries made of several packages (Tiptap, FullCalendar, Uppy, xterm, wavesurfer) take an object with the parts the adapter uses, so you choose which extensions or plugins to bundle:

import {createTiptap} from "xenia/tiptap"

const tiptap = createTiptap({
  load: async () => {
    const [core, starterKit, extensions] = await Promise.all([
      import("@tiptap/core"),
      import("@tiptap/starter-kit"),
      import("@tiptap/extensions"),
    ])
    return {
      Editor: core.Editor,
      StarterKit: starterKit.default,
      CharacterCount: extensions.CharacterCount,
      Placeholder: extensions.Placeholder,
    }
  },
})

Monaco

Xenia ships a Monaco adapter and a <.monaco> tag. Monaco is the heaviest library to set up, so here is all of it.

1. Install it: npm install monaco-editor@0.57.0 --save-exact --prefix assets

2. Create the adapter in assets/js/monaco.js. Monaco finds its web workers with new URL(..., import.meta.url), which esbuild doesn't bundle, so tell it where they are. The import("monaco-editor") is written here, in your app, so esbuild finds it in your assets/node_modules:

import {createMonaco} from "xenia/monaco"

const WORKERS = {
  json: "json", css: "css", scss: "css", less: "css",
  html: "html", handlebars: "html", razor: "html",
  typescript: "ts", javascript: "ts",
}

self.MonacoEnvironment = {
  getWorker: (_id, label) => new Worker(`/assets/js/monaco/${WORKERS[label] ?? "editor"}.worker.js`),
}

export const monaco = createMonaco({load: () => import("monaco-editor")})

Register it next to your other adapters: import {monaco} from "./monaco" and createHooks({Chart: chart, Monaco: monaco}). To add completion providers or commands, pass setup: (monaco, editor) => disposable to createMonaco.

3. Build the workers in config/config.exs: give app.js an entry name, add the five worker files, and a loader for Monaco's icon font:

config :esbuild,
  my_app: [
    args: ~w(app=js/app.js
             monaco/editor.worker=node_modules/monaco-editor/esm/vs/editor/editor.worker.js
             monaco/json.worker=node_modules/monaco-editor/esm/vs/language/json/json.worker.js
             monaco/css.worker=node_modules/monaco-editor/esm/vs/language/css/css.worker.js
             monaco/html.worker=node_modules/monaco-editor/esm/vs/language/html/html.worker.js
             monaco/ts.worker=node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js
             --bundle --target=es2022 --outdir=../priv/static/assets/js
             --external:/fonts/* --external:/images/* --alias:@=. --loader:.ttf=file),
    # cd and env as Phoenix generated them
  ]

Restart mix phx.server after changing config.exs.

4. Load Monaco's CSS. esbuild writes the CSS Monaco imports next to app.js. Add a second stylesheet to root.html.heex:

<link phx-track-static rel="stylesheet" href={~p"/assets/js/app.css"} />

5. Use it (import Xenia.Adapters.Monaco in html_helpers):

<.monaco id="editor" value={@code} version={@version} language="elixir"
         on_change="code_changed" style="height: 400px" />
def handle_event("code_changed", %{"value" => code, "version" => version}, socket) do
  {:noreply, assign(socket, code: code, version: version)}
end

Echo the client's version back from your change handler: that's how the editor tells your echo of its own typing apart from a real update. Pass version={nil} for an authoritative update. To force the server's value onto the editor, for example after rejecting an edit, increment revision. Commands: Xenia.push_to(socket, "editor", "focus"), and "insert" with %{text: "..."}.

The editor follows your app's theme (App-wide values): light → vs, dark → vs-dark. Bring your own themes with themes, a name or a full theme map per app theme, or pin one with theme:

<.monaco id="editor" themes={%{dark: %{name: "midnight", base: "vs-dark",
                                       colors: %{"editor.background" => "#0b1020"}}}} />

Monaco themes are global, so the last one applied wins for every editor.

Components in forms

Give a component a form field, and it is submitted with the form, fires the form's phx-change, and comes back after a reconnect like any input:

<.form for={@form} phx-change="validate" phx-submit="save">
  <.input field={@form[:title]} />
  <.monaco field={@form[:code]} language="elixir" />
  <.rating field={@form[:rating]} />
</.form>

The field gives the component its id, its input name and its value. <.xenia> takes field too. A declared component opts in by naming the prop that carries its value:

component :rating, adapter: "Rating", form_value: :value do
  prop :value, :any, default: 0
end

Xenia renders an invisible input inside the component, so errors appear only once the user has used the field, as with <.input>. Your adapter keeps the input equal to what the component shows:

// The user changed it: write the value and fire the form's phx-change.
node.addEventListener("sl-change", () => ctx.setFormValue(node.value))

props: {
  // The server changed it: write it quietly, the server already knows.
  value(node, value, ctx) {
    node.value = Number(value) // form values arrive as strings ("4")
    ctx.setFormValue(node.value, { notify: false })
  },
},

Outside a form, ctx.setFormValue does nothing, so one adapter works in both places. Monaco's adapter does all of this already.

Web components

A custom element is already most of an adapter: each prop is set as a property, so no prop handlers are needed. For example, Shoelace's rating:

import "@shoelace-style/shoelace/dist/components/rating/rating.js"

export const rating = {
  mount(el, props, ctx) {
    const node = Object.assign(document.createElement("sl-rating"), props)
    node.addEventListener("sl-change", () => ctx.emit("change", { value: node.value }))
    return el.appendChild(node)
  },
  destroy(node) { node.remove() },
  events: { change: {} },
}

Sending files to the server

Events carry JSON. To send a file a component produced (a recording, a drawing), use ctx.upload("recording", [file]). It goes through LiveView's upload channel, so the page needs a matching allow_upload and a <.live_file_input>. It returns false and logs the reason if the upload can't start.

App-wide values: theme, locale

Some values belong to the whole app, not one component. Publish them once, and every adapter that asks for them gets them as an ordinary prop:

export const chart = {
  consumes: ["theme"],
  mount(el, props) { /* props.theme is already here */ },
  props: {
    theme(chart, theme) { /* you decide what "dark" means to your library */ },
  },
  // ...
}

Publish from the browser, e.g. Phoenix 1.8's theme toggle, which keeps <html data-theme> at "light" or "dark". In app.js, before liveSocket.connect(), so components get the value at mount:

import {setProvider} from "xenia"

const publishTheme = () => setProvider("theme", document.documentElement.dataset.theme)
publishTheme()
new MutationObserver(publishTheme)
  .observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]})

Or from the server, in your layout, so every page publishes its own value:

<Xenia.Provider.provider name="locale" value={@locale} />

Use one source per name. An explicit prop wins over the published value: <.chart theme="light"> stays light in dark mode. Other JavaScript can read the same values with readProvider("theme") and subscribeProvider("theme", fn).

Pages from folders

Optional, but it's how most Xenia apps are built: your folders are your routes. Put a page.ex in a folder, and the folder's path is its URL, like the Next.js App Router. mix xenia.install sets this up in a new app.

1. Add one line to the router. It makes a route for every page in the folder:

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

scope "/", MyAppWeb do
  pipe_through :browser

  live_session :app do
    xenia_routes "lib/my_app_web/app"
  end
end

2. Make folders. Each folder's name says what it is:

lib/my_app_web/app/
  layout.ex                   frames every page
  page.ex                     /
  posts/[slug]/page.ex        /posts/:slug
  docs/[...path]/page.ex      /docs/*path
  (auth)/layout.ex            a frame and a sign-in check, for the pages inside
  (auth)/persistent.ex        a sidebar that stays alive across those pages
  (auth)/dashboard/page.ex    /dashboard
  settings/live.ex            one LiveView for the pages below
  settings/profile/page.ex    /settings/profile
  _components/card.ex         never a route
In the folderWhat it means
posts/A fixed part of the URL.
[slug]/A parameter. The page gets params["slug"].
[...path]/The rest of the URL, as a list: /docs/a/b gives params["path"] == ["a", "b"]. It also matches /docs, with [].
(auth)/A group. It isn't in the URL; its layout.ex applies only to the pages inside it.
_components/A private folder. Never a route; for code your pages share.
page.exThe page for its folder's URL.
layout.exA frame around every page in its folder and below. Layouts nest.
persistent.exNext to a layout.ex: a LiveView that stays alive while the user moves between that layout's pages, like a sidebar or a music player.
live.exOne LiveView for every page below it, so they share state, like the tabs of a settings screen.

3. Write the page. A page is an ordinary LiveView with use Xenia.Page:

# lib/my_app_web/app/posts/[slug]/page.ex
defmodule MyAppWeb.App.Posts.SlugPage do
  use MyAppWeb, :live_view
  use Xenia.Page

  def mount(%{"slug" => slug}, _session, socket) do
    {:ok, assign(socket, post: Blog.get_post!(slug))}
  end

  def render(assigns) do
    ~H"""
    <h1>{@post.title}</h1>
    """
  end
end

4. Frame pages with a layout. A layout draws @inner_content (the page) wherever it wants it. Its on_mount: runs before any page below it mounts, so this is where a group gets its sign-in check. The folder name (auth) grants nothing by itself.

# lib/my_app_web/app/(auth)/layout.ex
defmodule MyAppWeb.App.AuthLayout do
  use MyAppWeb, :html
  use Xenia.Layout, on_mount: {MyAppWeb.UserAuth, :require_authenticated}

  def frame(assigns) do
    ~H"""
    <div class="flex">
      <aside>{@persistent}</aside>
      <main>{@inner_content}</main>
    </div>
    """
  end
end

Everything generated is plain Phoenix. mix phx.routes lists the routes, ~p"/posts/hello" checks links at compile time, and mix xenia.routes also shows each page's layouts. Adding, renaming or deleting a page updates the routes without restarting the server, and two pages with the same URL are a compile error that names both files. mix xenia.gen.page "posts/[slug]" and mix xenia.gen.layout "(auth)" write these files for you.

Titles and social cards. A page and its layouts can define metadata/1. Xenia merges them, layouts first, and the page's title goes through the nearest layout's title_template:

# app/layout.ex
def metadata(_assigns), do: %{title: "Acme", title_template: "%s · Acme", description: "Rockets."}

# app/posts/[slug]/page.ex: computed after mount, so it can use the post
def metadata(assigns), do: %{title: assigns.post.title, description: assigns.post.summary}

<Xenia.Metadata.tags metadata={assigns[:xenia_metadata]} /> in root.html.heex renders the description, canonical URL, robots, and Open Graph and Twitter tags. The title becomes @page_title, so the browser tab follows live navigation. mix xenia.install sets this up.

A sitemap. One route lists every page in the folder:

get "/sitemap.xml", Xenia.Sitemap, []   # outside the :browser pipeline

Pages without parameters are listed. A page with parameters is listed for the values its sitemap/0 returns (def sitemap, do: [%{slug: "hello"}]), and use Xenia.Layout, sitemap: false leaves out every page below a layout, such as a signed-in area.

See it working in the examples app's File-based routing example. The guide, Building pages from folders, covers persistent parts, live.ex, metadata, the sitemap and navigation in detail.

Generators

mix xenia.gen.component chart data:map --events click   # adapter + tag; prints the app.js line
mix xenia.gen.component chart data:map --colocated      # prints a version to paste into a page
mix xenia.gen.layout "(auth)" --persistent              # layout.ex + persistent.ex in the app folder
mix xenia.gen.page reports/sales                        # page.ex in the app folder

The component's adapter works right away, showing the props it receives, until you replace it with your library. mix help xenia.gen.component and the others list their options.

Configuration

Binding prefix. If your LiveSocket uses a custom bindingPrefix, configure the same prefix for Xenia:

config :xenia, binding_prefix: "pop-"

Xenia as a path dependency ({:xenia, path: "../xenia"}). Mix creates no deps/xenia for a path dependency, so import ... from "xenia" can't be found. Add the folder that contains the xenia checkout to NODE_PATH in your esbuild config, for example when the two projects are siblings:

env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path(), Path.expand("../..", __DIR__)]}

Troubleshooting

"Could not resolve "xenia"". Run mix deps.get. With a path dependency, see the NODE_PATH note above.

"You must restart your server after changing configuration files". Restart mix phx.server after editing config.exs, such as the esbuild arguments.

Changes to a path dependency don't show up. If the app still sees an old xenia build, clear its cached copy: mix deps.clean xenia --build, then restart.

Examples

examples/ is a small Phoenix app with one page per feature. Run it with cd examples, mix setup and mix phx.server.

Developing Xenia

The JavaScript ships prebuilt in priv/static: xenia.esm.js for "xenia", and one xenia-<name>.esm.js per official adapter ("xenia/monaco", "xenia/tiptap", ...). Apps never build it.

Where things are:

lib/xenia.ex           Xenia.push_to/4
lib/xenia/core/        the JS-island boundary: <.xenia>, the component DSL, providers, shortcuts
lib/xenia/app/         pages from folders: routes, pages, layouts, metadata, sitemap
lib/xenia/adapters/    the official adapters' tags (Xenia.Adapters.Monaco, ...)
lib/mix/               mix xenia.install, xenia.routes and the generators
assets/core/           the JavaScript core: createHooks, the controller, providers
assets/adapters/       the official adapters' JavaScript
test/                  the same layout, plus test/js for the JavaScript
examples/              a runnable Phoenix app, one page per feature

To work on Xenia itself:

mix setup   # deps, esbuild, the bundles, npm packages for the JS tests
mix check   # format, compile, bundles, Elixir and JS tests, the Hex package

Commit the rebuilt priv/static with your change. CI fails if it differs from the sources.

Contact

Xenia is made by Uzay Durdu. Bugs, questions and ideas are welcome as GitHub issues. For anything else, including a security problem you'd rather not post publicly, use the contact page on uzaydurdu.com.

License

MIT. Xenia is free to use, in open-source and commercial projects alike.