defmodule Mix.Tasks.PhoenixLiveCalendar.Install do
@shortdoc "Installs PhoenixLiveCalendar in your Phoenix project"
@moduledoc """
Installs PhoenixLiveCalendar into your Phoenix project.
$ mix phoenix_live_calendar.install
This task:
1. Finds your `app.css` and adds an `@source` directive so Tailwind scans
PhoenixLiveCalendar's component templates
2. Finds your `app.js`, adds the JS hook import, and registers
`window.PhoenixLiveCalendarHooks` in your LiveSocket when it can do so safely
Both steps are idempotent — safe to run multiple times. When the task can't
edit a file automatically it prints exact manual instructions instead of
guessing.
## Options
- `--css-path` — Path to your app.css file (auto-detected if not specified)
- `--js-path` — Path to your app.js file (auto-detected if not specified)
"""
use Mix.Task
@css_marker "/* PhoenixLiveCalendar CSS Integration */"
@source_line ~s(@source "../../deps/phoenix_live_calendar";)
@js_marker "// PhoenixLiveCalendar JS hooks"
@js_import ~s(import "../../deps/phoenix_live_calendar/priv/static/assets/phoenix_live_calendar.js")
@hooks_snippet "hooks: { ...window.PhoenixLiveCalendarHooks, ...yourHooks }"
@css_paths [
"assets/css/app.css",
"priv/static/assets/app.css",
"assets/app.css"
]
@js_paths [
"assets/js/app.js",
"assets/app.js"
]
@impl true
def run(args) do
{opts, _, _} = OptionParser.parse(args, strict: [css_path: :string, js_path: :string])
Mix.shell().info("\n PhoenixLiveCalendar Install\n")
case find_path(opts[:css_path], @css_paths) do
{:ok, path} -> integrate_css(path)
{:error, :not_found} -> print_manual_css_instructions()
end
case find_path(opts[:js_path], @js_paths) do
{:ok, path} -> integrate_js(path)
{:error, :not_found} -> print_manual_js_instructions()
end
Mix.shell().info("")
end
defp find_path(nil, candidates) do
case Enum.find(candidates, &File.exists?/1) do
nil -> {:error, :not_found}
path -> {:ok, path}
end
end
defp find_path(path, _candidates) do
if File.exists?(path), do: {:ok, path}, else: {:error, :not_found}
end
# -- CSS --
defp integrate_css(path) do
content = File.read!(path)
cond do
String.contains?(content, @css_marker) ->
Mix.shell().info(" CSS already configured in #{path}")
String.contains?(content, "phoenix_live_calendar") ->
Mix.shell().info(" PhoenixLiveCalendar source already present in #{path}")
true ->
File.write!(path, inject_source(content))
Mix.shell().info(" Added PhoenixLiveCalendar source to #{path}")
end
end
defp inject_source(content) do
lines = String.split(content, "\n")
{before, after_lines} = css_insertion_point(lines)
insertion = "\n#{@css_marker}\n#{@source_line}\n"
Enum.join(before, "\n") <> insertion <> Enum.join(after_lines, "\n")
end
defp css_insertion_point(lines) do
last_source_idx =
lines
|> Enum.with_index()
|> Enum.filter(fn {line, _idx} -> String.starts_with?(String.trim(line), "@source") end)
|> List.last()
case last_source_idx do
{_line, idx} -> Enum.split(lines, idx + 1)
nil -> css_fallback_point(lines)
end
end
defp css_fallback_point(lines) do
import_idx =
lines
|> Enum.with_index()
|> Enum.find(fn {line, _} -> String.contains?(line, "tailwindcss") end)
case import_idx do
{_line, idx} -> Enum.split(lines, idx + 1)
nil -> {[], lines}
end
end
# -- JS --
defp integrate_js(path) do
content = File.read!(path)
cond do
String.contains?(content, @js_marker) ->
Mix.shell().info(" JS already configured in #{path}")
String.contains?(content, "phoenix_live_calendar") ->
Mix.shell().info(" PhoenixLiveCalendar JS import already present in #{path}")
unless String.contains?(content, "PhoenixLiveCalendarHooks"), do: print_hooks_instructions()
true ->
{new_content, hooks_wired?} = content |> inject_js_import() |> wire_hooks()
File.write!(path, new_content)
Mix.shell().info(" Added PhoenixLiveCalendar JS import to #{path}")
report_hooks_result(hooks_wired?)
end
end
defp inject_js_import(content) do
lines = String.split(content, "\n")
{before, after_lines} = Enum.split(lines, js_insertion_index(lines))
Enum.join(before ++ [@js_marker, @js_import] ++ after_lines, "\n")
end
# Insert right after the last top-level `import` line so the hooks global is
# defined before the LiveSocket is constructed; fall back to the top.
defp js_insertion_index(lines) do
lines
|> Enum.with_index()
|> Enum.filter(fn {line, _idx} -> String.starts_with?(String.trim(line), "import ") end)
|> List.last()
|> case do
{_line, idx} -> idx + 1
nil -> 0
end
end
# Only spread the hooks automatically when there's exactly one `hooks: {`
# object literal — anything else (e.g. `hooks: Hooks`) is left for the user
# so we never corrupt their app.js.
defp wire_hooks(content) do
cond do
String.contains?(content, "PhoenixLiveCalendarHooks") ->
{content, true}
count(content, "hooks: {") == 1 ->
{String.replace(content, "hooks: {", "hooks: { ...window.PhoenixLiveCalendarHooks, ",
global: false
), true}
true ->
{content, false}
end
end
defp count(content, substr), do: length(String.split(content, substr)) - 1
defp report_hooks_result(true),
do: Mix.shell().info(" Registered window.PhoenixLiveCalendarHooks in your LiveSocket")
defp report_hooks_result(false), do: print_hooks_instructions()
# -- Manual instructions --
defp print_manual_css_instructions do
Mix.shell().info("""
Could not find app.css automatically.
Add this line to your CSS file (after other @source directives):
#{@source_line}
Or run with --css-path:
mix phoenix_live_calendar.install --css-path assets/css/app.css
""")
end
defp print_manual_js_instructions do
Mix.shell().info("""
Could not find app.js automatically. Add to your assets/js/app.js:
#{@js_import}
Then register the hooks in your LiveSocket:
let liveSocket = new LiveSocket("/live", Socket, {
#{@hooks_snippet}
})
Or run with --js-path:
mix phoenix_live_calendar.install --js-path assets/js/app.js
""")
end
defp print_hooks_instructions do
Mix.shell().info("""
One more step — register the hooks in your LiveSocket (assets/js/app.js):
let liveSocket = new LiveSocket("/live", Socket, {
#{@hooks_snippet}
})
""")
end
end