Skip to content

Instantly share code, notes, and snippets.

@petermueller
Last active September 29, 2023 10:41
Show Gist options
  • Save petermueller/e038696c9c30d6596210980ce7de56c8 to your computer and use it in GitHub Desktop.
Save petermueller/e038696c9c30d6596210980ce7de56c8 to your computer and use it in GitHub Desktop.
Phoenix + esbuild + yarn + postcss + tailwindcss + cpx2

Getting started

If you just want to copy-paste this it should "just work", but you'll need to get yarn set up. Follow the instructions at https://yarnpkg.com/getting-started/install OR just run:

  • npm install -g yarn
  • yarn set version berry
  • yarn install # this should make a .yarn/, yarn.lock, and .pnp.js

Mix Tasks

If you want to run these outside of the context of a running phoenix server, you can also run the mix tasks, mix assets.install and mix assets.compile. They are a copy paste of some the CLI bits but you could abstract them out to point to a config/cmds.exs or something if you wanted.

esbuild

I am not using downloadable binary because I needed the ability to run plugins, which relies on node. If you don't need to run plugins, you can just use the binary, or check out https://github.com/phoenixframework/esbuild

Also until evanw/esbuild#1449 is reviewed and/or merged, the watch.js file will require chokidar to handle the loss of stdin while running the server.

postcss and tailwindcss

postcss-cli does not yet pass through dir-dependency messages to its plugins. postcss/postcss-cli#383 has been merged which should fix that, but as of writing it has not yet been released in a new version.

A Note on yarn

This uses yarn but there's no reason it wouldn't work just fine with node and npm, I just prefer yarn's consistency and Zero-Install approach. If you wanted to use npm there's a lot of things from this that could be removed, but it's mostly a matter of removing references to .pnp.js and yarn_path from the config/dev.exs and replacing them with npx and the like.

Troubleshooting

I suggest running pgrep -l "node|esbuild" before, during, and after running the server to confirm how many processes are running. If something is still running, it means something isn't right, and your processes are hanging around when they shouldn't. This isn't an issue with Phoenix or Elixir. It's an issue with how signals and interrupts are handled and propogated to children throughout different Unix-like systems. There is not a clear "right way" that has been adopted, especially within the CLI tools of many different languages.

If you do have zombie processes, you can run pkill -l "node|esbuild"

defmodule Mix.Tasks.Assets.Compile do
use Mix.Task
@moduledoc """
Compiles the JS and CSS, to `priv/static/{js|css}`,
then copies any remaining assets from `assets/static` to `priv/static`.
Runs:
- `esbuild` by calling `./build.js`
- `tailwindcss` as a `yarn` command, outputting to `assets/css/tailwind-build.css`
- `postcss` as a `yarn` command, compiling `assets/css/app.css` and it's dependencies, and outputting to `priv/static/css`
- `cpx` as a `yarn` command, copying files in `assets/static` to `priv/static`
## Command line options
- `--quiet`, will run the commands without shell output
- `--yarn [relative path]`, relative path to a `yarn` executable, defaults to `.yarn/releases/yarn-berry.cjs`
mix assets.compile --quiet
mix assets.compile --yarn .yarn/releases/yarn-berry.cjs
"""
@tailwind_args "tailwindcss --config ./assets/tailwind.config.js -o assets/css/tailwind-build.css"
@postcss_args "postcss ./assets/css/app.css --dir ./priv/static/css --config assets/"
@cpx_args ~s(cpx "assets/static/**/*" priv/static)
@yarn_path Path.expand("../../../.yarn/releases/yarn-berry.cjs", __DIR__)
@shortdoc "Compiles JS and CSS, and copies and other files from assets/static"
def run(args) do
{options, _, _} = OptionParser.parse(args, strict: [quiet: :boolean, yarn: :string])
yarn = options[:yarn] || @yarn_path
if options[:quiet] do
Mix.shell(Mix.Shell.Quiet)
end
node_env =
if Mix.env() == :prod do
System.get_env("NODE_ENV", "production")
else
System.get_env("NODE_ENV", "development")
end
opts = [env: [{"NODE_ENV", node_env}]]
shell = Mix.shell()
shell.info("Running `esbuild`")
shell.cmd("#{yarn} node ./build.js", opts)
shell.info("Running `tailwindcss`")
shell.cmd(yarn <> " " <> @tailwind_args, opts)
shell.info("Running `postcss`")
shell.cmd(yarn <> " " <> @postcss_args, opts)
shell.info("Copying other assets to priv/static")
shell.cmd(yarn <> " " <> @cpx_args, opts)
end
end
defmodule Mix.Tasks.Assets.Install do
use Mix.Task
@moduledoc """
Runs `yarn install`
## Command line options
- `--yarn [relative path]`, relative path to a `yarn` executable, defaults to `.yarn/releases/yarn-berry.cjs`
"""
@yarn_path Path.expand("../../../.yarn/releases/yarn-berry.cjs", __DIR__)
@shortdoc "Installs any missing assets using Yarn"
def run(args) do
yarn =
case OptionParser.parse(args, strict: [yarn: :string]) do
{[yarn: yarn_path], _, _} -> yarn_path
_ -> @yarn_path
end
Mix.shell().info("Running `yarn install`")
Mix.shell().cmd("#{yarn} install")
end
end
const { pnpPlugin } = require('@yarnpkg/esbuild-plugin-pnp')
const node_env = process.env.NODE_ENV
require('esbuild').build({
entryPoints: [
'assets/js/app.js',
'assets/js/other_top_level.js',
],
bundle: true,
minify: node_env === 'production',
sourcemap: true,
// only if you prefer not to ship the source, https://esbuild.github.io/api/#sources-content
sourcesContent: node_env === 'development',
target: 'es2016',
outdir: 'priv/static/js',
plugins: [pnpPlugin()],
})
# config/dev.exs
import Config
# ... other stuff
assets_path = Path.expand("../assets", __DIR__)
zombie = Path.expand("../zombie.sh", __DIR__)
yarn_path = Path.expand("../.yarn/releases/yarn-berry.cjs", __DIR__)
{tailwind, 0} = System.cmd(yarn_path, ["bin", "tailwindcss"])
{cpx, 0} = System.cmd(yarn_path, ["bin", "cpx"])
tailwind = String.trim(tailwind)
cpx = String.trim(cpx)
config :my_app, MyAppWeb.Endpoint,
url: [host: "localhost"],
http: [port: 4000],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [
# I'd love to just have: `{:node, ~w"-r ./.pnp.js watch.js"}`
# but Phoenix has some "helpful" bits when the watcher is `:node`
{yarn_path, ["node", "./watch.js"]},
{yarn_path,
~w"postcss ./assets/css/app.css --dir ./priv/static/css --config assets/ --watch"},
{zombie,
~w"node -r ./.pnp.js #{tailwind} --config ./assets/tailwind.config.js -o assets/css/tailwind-build.css --watch"},
{zombie, ~w"node -r ./.pnp.js #{cpx} assets/static/**/* priv/static --watch"}
]
# .yarnrc.yml
yarnPath: ".yarn/releases/yarn-berry.cjs"
{
"name": "my_app",
"devDependencies": {
"autoprefixer": "^10.2.6",
"chokidar": "^3.5.2",
"cpx2": "^3.0.0",
"cssnano": "^5.0.6",
"esbuild": "^0.12.9",
"postcss": "^8.3.5",
"postcss-cli": "^8.3.1",
"postcss-import": "^14.0.2"
},
"dependencies": {
"@tailwindcss/forms": "^0.3.3",
"@yarnpkg/esbuild-plugin-pnp": "^1.0.0-rc.9",
"alpinejs": "2.8.2",
"echarts": "^5.1.2",
"nprogress": "^0.2.0",
"phoenix": "link:./deps/phoenix",
"phoenix_html": "link:./deps/phoenix_html",
"phoenix_live_view": "link:./deps/phoenix_live_view",
"tailwindcss": "^2.2.2"
}
}
// assets/postcss.config.js
module.exports = (ctx) => ({
map: {
absolute: false,
sourcesContent: ctx.env === 'development'
},
plugins: {
'postcss-import': { root: ctx.file.dirname },
// commented out and running tailwindcss as its own watcher for now until a
// newer release of postcss-cli (v8.0.1 or something) that contains
// https://github.com/postcss/postcss-cli/pull/383
// see: https://tailwindcss.com/docs/just-in-time-mode#it-just-doesn-t-seem-to-work-properly
// tailwindcss: { config: './tailwind.config.js' }
autoprefixer: {},
cssnano: ctx.env === 'production' ? {preset: 'default'} : false
}
})
// assets/tailwind.config.js
// removed a lot of the theme customization, but Tailwind's docs are pretty good for figuring that part out
module.exports = {
mode: 'jit',
purge: {
content: [
// commented since we run "tailwindcss --watch" from the project root directory
// '../lib/my_app_web/templates/**/*.html.eex',
// '../lib/my_app_web/views/*.ex',
'./lib/my_app_web/templates/**/*.html.eex',
'./lib/my_app_web/views/*.ex'
]
},
darkMode: false, // or 'media' or 'class'
variants: {
extend: {
backgroundColor: ['active', 'disabled', 'even', 'odd'],
textColor: ['active', 'disabled']
},
},
plugins: [
require('@tailwindcss/forms'),
],
}
const chokidar = require("chokidar");
const { execSync } = require("child_process");
// Exit the process when standard input closes due to:
// https://hexdocs.pm/elixir/1.10.2/Port.html#module-zombie-operating-system-processes
//
process.stdin.on("end", function () {
console.log("standard input end");
process.exit();
});
process.stdin.resume();
// Set up chokidar to watch all js files and rebuild, ignoring process errors
// Reevaluate if/when https://github.com/evanw/esbuild/pull/1449 is merged
chokidar.watch("assets/js/**/*.js").on("all", (event, path) => {
console.log("esbuild: ", event, path);
try {
execSync("node -r ./.pnp.js ./build.js");
} catch (error) { }
});
#!/usr/bin/env bash
# This is DIRECTLY copy-pasted from the Elixir "Port" docs
# https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes
# Start the program in the background
exec "$@" &
pid1=$!
# Silence warnings from here on
exec >/dev/null 2>&1
# Read from stdin in the background and
# kill running program when stdin closes
exec 0<&0 $(
while read; do :; done
kill -KILL $pid1
) &
pid2=$!
# Clean up
wait $pid1
ret=$?
kill -KILL $pid2
exit $ret
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment