Creating an Elixir library with bundled JavaScript and CSS

We started developing a component library for Phoenix LiveView applications that use Surface. The library is called supabase-surface, and provides Surface components which are styled after Supabase’s component library for React. Some components also implement additional functionality, for example the Auth component. This component is responsible for handling the different authentication mechanisms that are offered by Supabase (e.g. magic link and social providers like Github), which had to be implemented via hooks with JavaScript. Because of the styling and the necessary JavaScript code, our library not only has to ship the Elixir code, but also the JS and CSS.

The Library

supabase-surface is a component library for Phoenix LiveView that builds on top of Surface. Components use the same styling as the official Supabase UI project, which is done with TailwindCSS. In addition to components, it provides other functionality, for example a Plug to check if there already is a valid access token in the current browser session, including handling refreshs for (almost) expired ones.

Some components also implement Phoenix hooks, to handle things that have to be implemented on the client or improve UX.

This means that a Hex release of supabase-surface has to contain all the styles and JS code in addition to the Elixir code.

Usage

Before looking at how to configure the package for Hex, let’s have a look at how the library can be used.

The library should be installed like every other Elixir package, by including it to mix.exs:

defp deps do
  [
	  ...
    {:supabase_surface, "~> 0.2.0"}
    ...
  ]
end

Client dependencies are added by extending assets/package.json:

  "dependencies": {
    ...
    "supabase_surface": "file:../deps/supabase_surface",
    ...
  }

To make hooks work, they have to be added to the LiveSocket initialization. In case the user already uses hooks with Surface, he might not have to do anything at all. The Surface compilation adds the generated supabase-surface hooks to the configured target location, which is assets/js/_hooks by default.

Because some components use Alpine.js to improve UX, the LiveSocket also has to be configured to add Alpine.js support:

import 'supabase_surface'

// this import might already be in the users project and will
// include all local hooks and the ones provided by supabase_surface
import hooks from './_hooks'

let liveSocket = new LiveSocket('/live', Socket, {
  params: { _csrf_token: csrfToken },
  // register hooks
  hooks: hooks,
  // this is necessary for Alpine.js
  dom: {
    onBeforeElUpdated(from, to) {
      if (from.__x) {
        window.Alpine.clone(from.__x, to)
      }
    },
  },
})

To include styles from supabase-surface, we can import it in a [s]css file, e.g. app.scss:

@import 'supabase_surface';

Creating the Hex Release

To create a Hex release, we have to add a package option to our Mix project, where we can configure meta data, like name and description of the package, its licenses and also which files should be included.

  def project do
    ...
    package: [
      name: "supabase_surface",
      description: "Supabase UI for Surface",
      licenses: ["Apache-2.0"],
      links: %{github: "https://github.com/treebee/supabase_surface"},
      files:
        ~w(lib .formatter.exs mix.exs README.md assets/js assets/css priv/static assets/package.json LICENSE package.json)
    ],
    ...
  end

How to handle the JavaScript, we looked at how it’s done for Phoenix LiveView. The interesting thing here is that an additional package.json is used. This lives in the project root and points to the actual files, the output of a JS production build. In our case ./priv/static/js/supabase_surface.js, which is the value of the main key.

To make @import 'supabase_surface' in a CSS file work, we point to our CSS output with the style option. The resulting package.json then looks like:

{
  "name": "supabase_surface",
  "version": "0.0.1",
  "description": "Surface Components for Supabase.",
  "license": "MIT",

  // paths depend on how used JS bundler is configured
  "main": "./priv/static/js/supabase_surface.js",
  "style": "./priv/static/css/app.css",

  "author": "Patrick Muehlbauer [email protected]>",
  "files": [
    "README.md",
    "LICENSE.md",
    "package.json",
    "priv/static/js/supabase_surface.js",
    "priv/static/css/app.css",
    "assets/js/supabase_surface.js"
  ]
}

/elixir/