Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for file watching #9

Merged
merged 5 commits into from
Nov 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,6 @@ lightning_css-*.tar

# Bundled assets
priv/static
!priv/static/.gitkeep
!priv/static/.gitkeep

.DS_Store
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,64 @@ def deps do
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/lightningcss>.
## Usage

After installing the package, you'll have to configure it in your project:

```elixir
# config/config.exs
config :lightning_css,
version: "1.22.0",
dev: [
args: ~w(assets/foo.css --bundle --output-dir=static),
watch_files: "assets/**/*.css",
cd: Path.expand("../priv", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
```

### Configuration options

- **version:** Indicates the version that the package will download and use. When absent, it defaults to the value of `@latest_version` at [`lib/lightning_css.ex`](./lib/lightning_css.ex).
- **profiles:** Additional keys in the configuration keyword list represent profiles. Profiles are a combination of attributes the Lightning CSS can be executed with. You can indicate the profile to use when invoking the Mix task by using the `--profile` flag, for example `mix lightning_css --profile dev`. A profile is represented by a keyword list with the following attributes:
- **args:** An list of strings representing the arguments that will be passed to the Lightning CSS executable.
- **watch_files (optional):** A glob pattern that will be used when Lightning CSS is invoked with `--watch` to match the file changes against it.
- **cd (optional):** The directory from where Lightning CSS is executed. When absent, it defaults to the project's root directory.
- **env (optional):** A set of environment variables to make available to the Lightning CSS process.

### Phoenix

If you are using the Phoenix framework, we recommend doing an integration similar to the one Phoenix proposes by default for Tailwind and ESBuild.

After adding the dependency and configuring it as described above with at least one profile, adjust your app's endpoint configuration to add a new watcher:

```elixir
config :my_app, MyAppWeb.Endpoint,
# ...other attributes
watchers: [
# :default is the name of the profile. Update it to match yours.
css: {LightningCSS, :install_and_run, [:default, ~w(), watch: true]}
]
```

Then update the `aliases` of your project's `mix.exs` file:

```elixir
defp aliases do
[
# ...other aliases
"assets.setup": [
# ...other assets.setup tasks
"lightning_css.install --if-missing"
],
"assets.build": [
# ...other assets.build tasks
"lightning_css default",
],
"assets.deploy": [
# ...other deploy tasks
"lightning_css default",
]
]
end
```
1 change: 1 addition & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ config :lightning_css,
version: "1.22.0",
dev: [
args: ~w(assets/foo.css --bundle --output-dir=static),
watch_files: "assets",
cd: Path.expand("../priv", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
82 changes: 45 additions & 37 deletions lib/lightning_css.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,6 @@ defmodule LightningCSS do
Application.get_env(:lightning_css, :version, latest_version())
end

@doc """
Returns the configuration for the given profile.

Returns nil if the profile does not exist.
"""
def config_for!(profile) when is_atom(profile) do
Application.get_env(:lightning_css, profile) ||
raise ArgumentError, """
unknown lightning_css profile. Make sure the profile is defined in your config/config.exs file, such as:

config :lightning_css,
#{profile}: [
args: ~w(css/app.css --bundle --targets='last 10 versions' --output-dir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
"""
end

@doc """
Returns the most recent Lightning CSS version known by this package.
"""
Expand Down Expand Up @@ -145,25 +126,33 @@ defmodule LightningCSS do
The task output will be streamed directly to stdio. It
returns the status of the underlying call.
"""
def run(profile, extra_args) when is_atom(profile) and is_list(extra_args) do
config = config_for!(profile)
args = config[:args] || []

if args == [] and extra_args == [] do
raise "no arguments passed to lightning_css"
end
def run(profile, extra_args, opts) when is_atom(profile) and is_list(extra_args) do
watch = opts |> Keyword.get(:watch, false)

id = ([profile] ++ extra_args ++ [watch]) |> Enum.map_join("_", &to_string/1) |> String.to_atom()

opts = [
cd: config[:cd] || File.cwd!(),
env: config[:env] || %{},
into: IO.stream(:stdio, :line),
stderr_to_stdout: true
]
ref =
__MODULE__.Supervisor
|> Supervisor.start_child(
Supervisor.child_spec({LightningCSS.Runner, %{
profile: profile,
extra_args: extra_args,
watch: watch
}}, id: id, restart: :transient)
)
|> case do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
|> Process.monitor()

bin_path()
|> System.cmd(args ++ extra_args, opts)
|> elem(1)
receive do
{:DOWN, ^ref, _, _, _} ->
:ok
_ ->
:ok
end
0
end


Expand All @@ -172,10 +161,10 @@ defmodule LightningCSS do

Returns the same as `run/2`.
"""
def install_and_run(profile, args) do
def install_and_run(profile, args, opts \\ []) do
File.exists?(bin_path()) || start_unique_install_worker()

run(profile, args)
run(profile, args, opts)
end

defp start_unique_install_worker() do
Expand All @@ -196,6 +185,25 @@ defmodule LightningCSS do
end


@doc """
Returns the configuration for the given profile.

Returns nil if the profile does not exist.
"""
def config_for!(profile) when is_atom(profile) do
Application.get_env(:lightning_css, profile) ||
raise ArgumentError, """
unknown lightning_css profile. Make sure the profile is defined in your config/config.exs file, such as:

config :lightning_css,
#{profile}: [
args: ~w(css/app.css --bundle --targets='last 10 versions' --output-dir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
"""
end

@doc """
Installs lightning_css with `configured_version/0`.
"""
Expand Down
105 changes: 105 additions & 0 deletions lib/lightning_css/runner.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule LightningCSS.Runner do
@moduledoc false

use GenServer
require Logger

@type init_args :: %{
profile: atom(),
extra_args: [String.t()],
watch: boolean() }

@spec start_link(args :: init_args) :: GenServer.on_start()
def start_link(args) do
GenServer.start_link(__MODULE__, args)
end

@spec init(args :: init_args) :: {:ok, any()}
def init(%{profile: profile, extra_args: _, watch: watch} = args) do
config = LightningCSS.config_for!(profile)
cd = config |> Keyword.get(:cd, File.cwd!())

watcher_pid = case {watch, config[:watch_files]} do
{true, glob} when is_binary(glob) ->
dirs = Path.join(cd, glob)
Logger.info("Watching #{dirs}")
{:ok, watcher_pid} = FileSystem.start_link(dirs: [dirs])
FileSystem.subscribe(watcher_pid)
watcher_pid
_ -> nil
end

args = args |> Map.put(:config, config) |> Map.put(:cd, cd) |> Map.put(:watcher_pid, watcher_pid)

gen_server_pid = self()
args = args |> Map.put(:gen_server_pid, gen_server_pid)

%{pid: process_pid} = Task.async(fn ->
Logger.info("Running Lightning CSS")
__MODULE__.run_lightning_css(args)
end)

args = args |> Map.put(:process_pid, process_pid)

{:ok, args}
end

def run_lightning_css(%{config: config, extra_args: extra_args, cd: cd, gen_server_pid: gen_server_pid}) do
args = config[:args] || []

if args == [] and extra_args == [] do
raise "no arguments passed to lightning_css"
end

opts = [
cd: cd,
env: config[:env] || %{},
into: IO.stream(:stdio, :line),
stderr_to_stdout: true
]

command_string = ([Path.relative_to_cwd(LightningCSS.bin_path())] ++ args ++ extra_args) |> Enum.join(" ")
Logger.debug("Command: #{command_string}", opts)
exit_status = LightningCSS.bin_path()
|> System.cmd(args ++ extra_args, opts)
|> elem(1)
Logger.debug("Command completed with exit status #{exit_status}")
send(gen_server_pid, {:lightning_css_exited, exit_status})
end

def handle_info({:file_event, _watcher_pid, {_path, _events}}, state) do
%{pid: process_pid} = Task.async(fn ->
Logger.info("Changes detected. Running Lightning CSS")
__MODULE__.run_lightning_css(state)
end)
state = state |> Map.put(:process_pid, process_pid)
{:noreply, state}
end

def handle_info({:file_event, watcher_pid, :stop}, %{watcher_pid: watcher_pid} = state) do
{:stop, :normal, state}
end

def handle_info({:lightning_css_exited, exit_status}, %{watch: watch} = state) do
case {watch, exit_status} do
{true, 0} -> {:noreply, state }
{false, 0} -> {:stop, :normal, state}
{true, _status} ->
{:no_reply, state}
{false, status} ->
{:stop, {:error_and_no_watch, status}, state}
end
end

def handle_info(_, state) do
{:noreply, state}
end

def terminate(:normal, _state) do
:ok
end

def terminate(_, _) do
:ok
end
end
20 changes: 12 additions & 8 deletions lib/mix/tasks/lightning_css.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,52 @@ defmodule Mix.Tasks.LightningCss do
Runs lightning_css with the given profile and args.

```bash
$ mix lightning_css --runtime-profile dev
$ mix lightning_css --runtime-config dev
```

The task will install lightning_css if it hasn't been installed previously
via the `mix lightning_css.install` task.

## Options

* `--profile` - the profile to use.
* `--runtime-config` - load the runtime configuration
before executing command
* `--watch` - watches for file changes and re-runs Lightning CSS when any of the matched files changes.

"""

use Mix.Task

@impl true
def run(args) do
switches = [profile: :boolean]
switches = [runtime_config: :boolean, watch: :boolean]
{opts, remaining_args} = OptionParser.parse_head!(args, switches: switches)

if function_exported?(Mix, :ensure_application!, 1) do
Mix.ensure_application!(:inets)
Mix.ensure_application!(:ssl)
end

if opts[:profile] do
if opts[:runtime_config] do
# Loads and configures all registered apps.
Mix.Task.run("app.config")
else
# Ensures that the application and their child application are started.
Application.ensure_all_started(:lightning_css)
end

Mix.Task.reenable("lightning_css")
install_and_run(remaining_args)
install_and_run(remaining_args, [watch: Keyword.get(opts, :watch, false)])
end

defp install_and_run([profile | args] = all) do
case LightningCSS.install_and_run(String.to_atom(profile), args) do
defp install_and_run([profile | args] = all, opts) do
case LightningCSS.install_and_run(String.to_atom(profile), args, opts) do
0 -> :ok
status -> Mix.raise("`mix lightning_css #{Enum.join(all, " ")}` exited with #{status}")
end
end

defp install_and_run([]) do
defp install_and_run([], _opts) do
Mix.raise("`mix lightning_css` expects the profile as argument")
end
end
Loading