The Missing Bit

Configuring esbuild with phoenix and tailwind
2022-03-15

phoenix esbuild javascript

This is a quick setup for phoenix 1.6, live view and tailwind for CSS.

File tree:


├── assets
│   ├── build.js
│   ├── css
│   │   └── app.css
│   ├── js
│   │   └── app.js
│   ├── Makefile
│   ├── package.json
│   ├── tailwind.config.js
├── Makefile
├── priv
│   └── static
│       ├── favicon.ico
│       ├── images
│       │   └── phoenix.png
│       └── robots.txt

This is a summary of the main files required for this setup.

All static assets (images...) go into /priv/static


@tailwind base;
@tailwind components;
@tailwind utilities;


import 'phoenix_html'

// Establish Phoenix Socket and LiveView configuration.
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content')
const liveSocket = new LiveSocket('/live', Socket, { params: { _csrf_token: csrfToken } })

// connect if there are any LiveViews on the page
liveSocket.connect()

// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000)  // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket


{
  "dependencies": {
    "autoprefixer": "^10.4.2",
    "chokidar": "^3.5.3",
    "csso-cli": "^3.0.0",
    "esbuild": "^0.14.11",
    "esbuild-style-plugin": "^1.2.0",
    "hsluv": "^0.1.0",
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view",
    "postcss-import": "^14.0.2",
    "postcss-nested": "^5.0.6",
    "postcss-url": "^10.1.3",
    "tailwindcss": "^3.0.12"
  },
  "license": "MIT"
}

#!/usr/bin/env node

const postCssPlugin = require('esbuild-style-plugin')
const path = require('path')
const chokidar = require('chokidar')

const watch = process.argv.indexOf('watch') >= 0
const css = process.argv.indexOf('css') >= 0
const js = process.argv.indexOf('js') >= 0

const entries = []
const paths = ['./js/**/*.{html,js}']

if (js) {
  entries.push('js/app.js')
}
if (css) {
  paths.push('../lib/**/*.{heex,ex,exs}')
  paths.push('./css/**/*.{html,js,css}')
  entries.push('css/app.css')
}

if (watch) {
  process.stdin.on('end', () => process.exit(0))
  process.stdin.resume()
}

async function run () {
  const builder = await require('esbuild')
    .build({
      plugins: [
        postCssPlugin({
          postcss: [
            require('postcss-import')({
              path: [
                path.join(__dirname, 'assets/css'),
                path.join(__dirname, 'assets')
              ]
            }),
            require('postcss-url')({ url: 'inline' }),
            require('postcss-nested'),
            require('autoprefixer'),
            require('tailwindcss')()
          ]
        })
      ],
      logLevel: 'info',
      entryPoints: entries,
      bundle: true,
      minify: process.env.NODE_ENV == 'production',
      sourcemap: process.env.NODE_ENV == 'production' ? false : 'both',
      outdir: '../priv/static/assets/',
      outbase: './',
      incremental: watch
    })
    .catch(() => process.exit(1))

  let building = false

  if (watch) {
    await chokidar.watch(paths).on('all',  (event, path) => {
      if (building === false) {
        building = true
        builder.rebuild().then(() => building = false)
      }
    })
  }

  // await builder.rebuild()
  if (!watch) {
    process.exit(0)
  }
}

run()

The hsluv and genShades part is of course optional, but I share this tips as you might find is practical.

Only content is required for the setup to work.

The rest are additions I like (spacing...).

const hsluv = require('hsluv')

function hsluvaToRgba (h, s, l, a) {
  if (a === undefined) {
    a = 1
  }
  const rgb = hsluv.hsluvToRgb([h, s, l])
  return 'rgba(' +
    (Math.round(255 * rgb[0])) + ', ' +
    (Math.round(255 * rgb[1])) + ', ' +
    (Math.round(255 * rgb[2])) + ', ' +
    a + ')'
}

function genShades (h, s) {
  const res = {}
  for (let i = 1; i < 20; i++) {
    res[50 * i] = hsluv.hsluvToHex([h, s, 100 - 5 * i])
  }
  return res
}

module.exports = {
  content: [
    '../lib/**/*.{heex,ex,exs}',
    './js/**/*.{html,js}',
    './css/**/*.{html,js}'
  ],
  theme: {
    extend: {
      colors: {
        red: genShades(100, 60),
        blue: genShades(120, 60)
      },
      spacing: {
        72: '18rem',
        84: '21rem',
        96: '24rem'
      },
      width: {
        128: '32rem',
        192: '48rem',
        256: '64rem'
      },
      maxWidth: {
        '2xs': '10rem'
      }
    }
  }
}

in config/dev.exs change watchers to

  watchers: [
    make: ["js-watch"],
    make: ["css-watch"],
  ]

Finally, the makefiles (you are not forced to use makefiles, but I like them).

Root Makefile:


# ...

.PHONY: assets

assets:
	cd assets && make build

.PHONY: js-watch

js-watch:
	cd assets && make js-watch

.PHONY: css-watch

css-watch:
	cd assets && make css-watch

# ...

assets/Makefile


SHELL := /bin/sh
PATH := ./node_modules/.bin:$(PATH)


.PHONY: build

build: export NODE_ENV=production
build: node_modules js css optimize

.PHONY: js-watch

js-watch: node_modules
	./build.js watch js

.PHONY: css-watch

css-watch: node_modules
	./build.js watch css

.PHONY: js

js: node_modules
	./build.js js

.PHONY: css

css: node_modules
	./build.js css

.PHONY: optimize
optimize: node_modules
	csso ../priv/static/assets/css/app.css --output ../priv/static/assets/css/app.css

.PHONY: clean

clean:
	rm -fr node_modules
	rm -fr ../priv/static/assets/
	rm -fr npm-debug.log*

yarn.lock:
	yarn

node_modules: yarn.lock package.json
	yarn


.PHONY: deps

deps: node_modules
If you wish to comment or discuss this post, just mention me on Bluesky or