Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions config/e2e.exs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Config

config :logger, :level, :error

config :phoenix_live_view, :root_tag_attribute, "phx-r"
6 changes: 3 additions & 3 deletions lib/mix/tasks/compile/phoenix_live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
@moduledoc """
A LiveView compiler for HEEx macro components.

Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS`
are handled.
Right now, only `Phoenix.LiveView.ColocatedHook`, `Phoenix.LiveView.ColocatedJS`,
and `Phoenix.LiveView.ColocatedCSS` are handled.

You must add it to your `mix.exs` as:

Expand All @@ -29,6 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
end

defp compile do
Phoenix.LiveView.ColocatedJS.compile()
Phoenix.LiveView.ColocatedAssets.compile()
end
end
13 changes: 7 additions & 6 deletions lib/phoenix_component/macro_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -162,18 +162,19 @@ defmodule Phoenix.Component.MacroComponent do
@doc """
Returns the stored data from macro components that returned `{:ok, ast, data}`.

As one macro component can be used multiple times in one module, the result is a list of all data values.
As one macro component can be used multiple times in one module, the result is a map of format

If the component module does not have any macro components defined, an empty list is returned.
%{module => list(data)}

If the component module does not have any macro components defined, an empty map is returned.
"""
@spec get_data(module(), module()) :: [term()] | nil
def get_data(component_module, macro_component) do
@spec get_data(module()) :: map()
def get_data(component_module) do
if Code.ensure_loaded?(component_module) and
function_exported?(component_module, :__phoenix_macro_components__, 0) do
component_module.__phoenix_macro_components__()
|> Map.get(macro_component, [])
else
[]
%{}
end
end

Expand Down
275 changes: 275 additions & 0 deletions lib/phoenix_live_view/colocated_assets.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
defmodule Phoenix.LiveView.ColocatedAssets do
@moduledoc false

defstruct [:relative_path, :data]

@type t() :: %__MODULE__{
relative_path: String.t(),
data: term()
}

defmodule Entry do
@moduledoc false
defstruct [:filename, :data, :callback, :component]
end

@callback build_manifests(colocated :: list(t())) :: list({binary(), binary()})

@doc """
Extracts content into the colocated directory.

Returns an opaque struct that is stored as macro component data
for manifest generation.

The flow is:

1. MacroComponent transform callback is called.
2. The transform callback invokes ColocatedAssets.extract/5,
which writes the content to the target directory.
3. LiveView compiler invokes ColocatedAssets.compile/0.
4. ColocatedAssets builds a list of `%ColocatedAssets{}` structs
grouped by callback module and invokes the callback's
`build_manifests/1` function.

"""
def extract(callback_module, module, filename, text, data) do
# _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/filename
target_path =
target_dir()
|> Path.join(inspect(module))

File.mkdir_p!(target_path)
File.write!(Path.join(target_path, filename), text)

%Entry{filename: filename, data: data, callback: callback_module}
end

@doc false
def compile do
# this step runs after all modules have been compiled
# so we can write the final manifests and remove outdated files
clear_manifests!()
callback_colocated_map = clear_outdated_and_get_files!()
File.mkdir_p!(target_dir())

warn_for_outdated_config!()

Enum.each(configured_callbacks(), fn callback_module ->
true = Code.ensure_loaded?(callback_module)

files =
case callback_colocated_map do
%{^callback_module => files} ->
files

_ ->
[]
end

for {name, content} <- callback_module.build_manifests(files) do
File.write!(Path.join(target_dir(), name), content)
end
end)

maybe_link_node_modules!()
end

defp clear_manifests! do
target_dir = target_dir()

manifests =
Path.wildcard(Path.join(target_dir, "*"))
|> Enum.filter(&File.regular?(&1))

for manifest <- manifests, do: File.rm!(manifest)
end

defp clear_outdated_and_get_files! do
target_dir = target_dir()
modules = subdirectories(target_dir)

modules
|> Enum.flat_map(fn module_folder ->
module = Module.concat([Path.basename(module_folder)])
process_module(module_folder, module)
end)
|> Enum.group_by(fn {callback, _file} -> callback end, fn {_callback, file} -> file end)
end

defp process_module(module_folder, module) do
with true <- Code.ensure_loaded?(module),
data when data != %{} <- Phoenix.Component.MacroComponent.get_data(module),
colocated when colocated != [] <- filter_colocated(data) do
expected_files = Enum.map(colocated, fn %{filename: filename} -> filename end)
files = File.ls!(module_folder)

outdated_files = files -- expected_files

for file <- outdated_files do
File.rm!(Path.join(module_folder, file))
end

Enum.map(colocated, fn %Entry{} = e ->
absolute_path = Path.join(module_folder, e.filename)

{e.callback,
%__MODULE__{relative_path: Path.relative_to(absolute_path, target_dir()), data: e.data}}
end)
else
_ ->
# either the module does not exist any more or
# does not have any colocated assets
File.rm_rf!(module_folder)
[]
end
end

defp filter_colocated(data) do
for {macro_component, entries} <- data do
Enum.flat_map(entries, fn data ->
case data do
%Entry{} = d -> [%{d | component: macro_component}]
_ -> []
end
end)
end
|> List.flatten()
end

defp maybe_link_node_modules! do
Comment thread
SteffenDE marked this conversation as resolved.
settings = project_settings()

case Keyword.get(settings, :node_modules_path, {:fallback, "assets/node_modules"}) do
{:fallback, rel_path} ->
location = Path.absname(rel_path)
do_symlink(location, true)

path when is_binary(path) ->
location = Path.absname(path)
do_symlink(location, false)
end
end

defp relative_to_target(location) do
if function_exported?(Path, :relative_to, 3) do
apply(Path, :relative_to, [location, target_dir(), [force: true]])
else
Path.relative_to(location, target_dir())
end
end

defp do_symlink(node_modules_path, is_fallback) do
relative_node_modules_path = relative_to_target(node_modules_path)

with {:error, reason} when reason != :eexist <-
File.ln_s(relative_node_modules_path, Path.join(target_dir(), "node_modules")),
false <- Keyword.get(global_settings(), :disable_symlink_warning, false) do
disable_hint = """
If you don't use colocated hooks / js / css or you don't need to import files from "assets/node_modules"
in your colocated assets, you can simply disable this warning by setting

config :phoenix_live_view, :colocated_assets,
disable_symlink_warning: true
"""

IO.warn("""
Failed to symlink node_modules folder for colocated assets: #{inspect(reason)}

See the documentation for Phoenix.LiveView.ColocatedJS for details.

On Windows, you can address this issue by starting your Windows terminal at least once
with "Run as Administrator" and then running your Phoenix application.#{is_fallback && "\n\n" <> disable_hint}
""")
end
end

defp configured_callbacks do
[
# Hardcoded for now
Phoenix.LiveView.ColocatedJS,
Phoenix.LiveView.ColocatedCSS
Comment on lines +188 to +190
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too happy about this, but not sure what a better way would be.

]
end

defp global_settings do
Application.get_env(
:phoenix_live_view,
:colocated_assets,
Application.get_env(:phoenix_live_view, :colocated_js, [])
)
end

defp project_settings do
lv_config =
Mix.Project.config()
|> Keyword.get(:phoenix_live_view, [])

Keyword.get_lazy(lv_config, :colocated_assets, fn ->
Keyword.get(lv_config, :colocated_js, [])
end)
end

defp target_dir do
app = to_string(Mix.Project.config()[:app])
default = Path.join(Mix.Project.build_path(), "phoenix-colocated")

global_settings()
|> Keyword.get(:target_directory, default)
|> Path.join(app)
end

defp subdirectories(path) do
Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1))
end

defp warn_for_outdated_config! do
Comment thread
SteffenDE marked this conversation as resolved.
case Application.get_env(:phoenix_live_view, :colocated_js) do
nil ->
:ok

_ ->
IO.warn("""
The :colocated_js configuration option is deprecated!

Instead of

config :phoenix_live_view, :colocated_js, ...

use

config :phoenix_live_view, :colocated_assets, ...

""")
end

lv_config =
Mix.Project.config()
|> Keyword.get(:phoenix_live_view, [])

case Keyword.get(lv_config, :colocated_js) do
nil ->
:ok

_ ->
IO.warn("""
The :colocated_js configuration option is deprecated!

Instead of

[
...,
phoenix_live_view: [colocated_js: ...]
]

use

[
...,
phoenix_live_view: [colocated_assets: ...]
]

in your mix.exs instead.
Comment thread
SteffenDE marked this conversation as resolved.
Outdated
""")
end
end
end
Loading
Loading