Skip to content

Commit 2c85671

Browse files
ColocatedAssets (+ ColocatedCSS) (#4138)
Co-authored-by: David Green <134172184+green-david@users.noreply.github.com>
1 parent 3406931 commit 2c85671

17 files changed

Lines changed: 1258 additions & 193 deletions

File tree

config/e2e.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
import Config
22

33
config :logger, :level, :error
4+
5+
config :phoenix_live_view, :root_tag_attribute, "phx-r"

lib/mix/tasks/compile/phoenix_live_view.ex

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
22
@moduledoc """
33
A LiveView compiler for HEEx macro components.
44
5-
Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS`
6-
are handled.
5+
Right now, only `Phoenix.LiveView.ColocatedHook`, `Phoenix.LiveView.ColocatedJS`,
6+
and `Phoenix.LiveView.ColocatedCSS` are handled.
77
88
You must add it to your `mix.exs` as:
99
@@ -29,6 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
2929
end
3030

3131
defp compile do
32-
Phoenix.LiveView.ColocatedJS.compile()
32+
Phoenix.LiveView.ColocatedAssets.compile()
3333
end
3434
end

lib/phoenix_component/macro_component.ex

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,18 +162,19 @@ defmodule Phoenix.Component.MacroComponent do
162162
@doc """
163163
Returns the stored data from macro components that returned `{:ok, ast, data}`.
164164
165-
As one macro component can be used multiple times in one module, the result is a list of all data values.
165+
As one macro component can be used multiple times in one module, the result is a map of format
166166
167-
If the component module does not have any macro components defined, an empty list is returned.
167+
%{module => list(data)}
168+
169+
If the component module does not have any macro components defined, an empty map is returned.
168170
"""
169-
@spec get_data(module(), module()) :: [term()] | nil
170-
def get_data(component_module, macro_component) do
171+
@spec get_data(module()) :: map()
172+
def get_data(component_module) do
171173
if Code.ensure_loaded?(component_module) and
172174
function_exported?(component_module, :__phoenix_macro_components__, 0) do
173175
component_module.__phoenix_macro_components__()
174-
|> Map.get(macro_component, [])
175176
else
176-
[]
177+
%{}
177178
end
178179
end
179180

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
defmodule Phoenix.LiveView.ColocatedAssets do
2+
@moduledoc false
3+
4+
defstruct [:relative_path, :data]
5+
6+
@type t() :: %__MODULE__{
7+
relative_path: String.t(),
8+
data: term()
9+
}
10+
11+
defmodule Entry do
12+
@moduledoc false
13+
defstruct [:filename, :data, :callback, :component]
14+
end
15+
16+
@callback build_manifests(colocated :: list(t())) :: list({binary(), binary()})
17+
18+
@doc """
19+
Extracts content into the colocated directory.
20+
21+
Returns an opaque struct that is stored as macro component data
22+
for manifest generation.
23+
24+
The flow is:
25+
26+
1. MacroComponent transform callback is called.
27+
2. The transform callback invokes ColocatedAssets.extract/5,
28+
which writes the content to the target directory.
29+
3. LiveView compiler invokes ColocatedAssets.compile/0.
30+
4. ColocatedAssets builds a list of `%ColocatedAssets{}` structs
31+
grouped by callback module and invokes the callback's
32+
`build_manifests/1` function.
33+
34+
"""
35+
def extract(callback_module, module, filename, text, data) do
36+
# _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/filename
37+
target_path =
38+
target_dir()
39+
|> Path.join(inspect(module))
40+
41+
File.mkdir_p!(target_path)
42+
File.write!(Path.join(target_path, filename), text)
43+
44+
%Entry{filename: filename, data: data, callback: callback_module}
45+
end
46+
47+
@doc false
48+
def compile do
49+
# this step runs after all modules have been compiled
50+
# so we can write the final manifests and remove outdated files
51+
clear_manifests!()
52+
callback_colocated_map = clear_outdated_and_get_files!()
53+
File.mkdir_p!(target_dir())
54+
55+
warn_for_outdated_config!()
56+
57+
Enum.each(configured_callbacks(), fn callback_module ->
58+
true = Code.ensure_loaded?(callback_module)
59+
60+
files =
61+
case callback_colocated_map do
62+
%{^callback_module => files} ->
63+
files
64+
65+
_ ->
66+
[]
67+
end
68+
69+
for {name, content} <- callback_module.build_manifests(files) do
70+
File.write!(Path.join(target_dir(), name), content)
71+
end
72+
end)
73+
74+
maybe_link_node_modules!()
75+
end
76+
77+
defp clear_manifests! do
78+
target_dir = target_dir()
79+
80+
manifests =
81+
Path.wildcard(Path.join(target_dir, "*"))
82+
|> Enum.filter(&File.regular?(&1))
83+
84+
for manifest <- manifests, do: File.rm!(manifest)
85+
end
86+
87+
defp clear_outdated_and_get_files! do
88+
target_dir = target_dir()
89+
modules = subdirectories(target_dir)
90+
91+
modules
92+
|> Enum.flat_map(fn module_folder ->
93+
module = Module.concat([Path.basename(module_folder)])
94+
process_module(module_folder, module)
95+
end)
96+
|> Enum.group_by(fn {callback, _file} -> callback end, fn {_callback, file} -> file end)
97+
end
98+
99+
defp process_module(module_folder, module) do
100+
with true <- Code.ensure_loaded?(module),
101+
data when data != %{} <- Phoenix.Component.MacroComponent.get_data(module),
102+
colocated when colocated != [] <- filter_colocated(data) do
103+
expected_files = Enum.map(colocated, fn %{filename: filename} -> filename end)
104+
files = File.ls!(module_folder)
105+
106+
outdated_files = files -- expected_files
107+
108+
for file <- outdated_files do
109+
File.rm!(Path.join(module_folder, file))
110+
end
111+
112+
Enum.map(colocated, fn %Entry{} = e ->
113+
absolute_path = Path.join(module_folder, e.filename)
114+
115+
{e.callback,
116+
%__MODULE__{relative_path: Path.relative_to(absolute_path, target_dir()), data: e.data}}
117+
end)
118+
else
119+
_ ->
120+
# either the module does not exist any more or
121+
# does not have any colocated assets
122+
File.rm_rf!(module_folder)
123+
[]
124+
end
125+
end
126+
127+
defp filter_colocated(data) do
128+
for {macro_component, entries} <- data do
129+
Enum.flat_map(entries, fn data ->
130+
case data do
131+
%Entry{} = d -> [%{d | component: macro_component}]
132+
_ -> []
133+
end
134+
end)
135+
end
136+
|> List.flatten()
137+
end
138+
139+
defp maybe_link_node_modules! do
140+
settings = project_settings()
141+
142+
case Keyword.get(settings, :node_modules_path, {:fallback, "assets/node_modules"}) do
143+
{:fallback, rel_path} ->
144+
location = Path.absname(rel_path)
145+
do_symlink(location, true)
146+
147+
path when is_binary(path) ->
148+
location = Path.absname(path)
149+
do_symlink(location, false)
150+
end
151+
end
152+
153+
defp relative_to_target(location) do
154+
if function_exported?(Path, :relative_to, 3) do
155+
apply(Path, :relative_to, [location, target_dir(), [force: true]])
156+
else
157+
Path.relative_to(location, target_dir())
158+
end
159+
end
160+
161+
defp do_symlink(node_modules_path, is_fallback) do
162+
relative_node_modules_path = relative_to_target(node_modules_path)
163+
164+
with {:error, reason} when reason != :eexist <-
165+
File.ln_s(relative_node_modules_path, Path.join(target_dir(), "node_modules")),
166+
false <- Keyword.get(global_settings(), :disable_symlink_warning, false) do
167+
disable_hint = """
168+
If you don't use colocated hooks / js / css or you don't need to import files from "assets/node_modules"
169+
in your colocated assets, you can simply disable this warning by setting
170+
171+
config :phoenix_live_view, :colocated_assets,
172+
disable_symlink_warning: true
173+
"""
174+
175+
IO.warn("""
176+
Failed to symlink node_modules folder for colocated assets: #{inspect(reason)}
177+
178+
See the documentation for Phoenix.LiveView.ColocatedJS for details.
179+
180+
On Windows, you can address this issue by starting your Windows terminal at least once
181+
with "Run as Administrator" and then running your Phoenix application.#{is_fallback && "\n\n" <> disable_hint}
182+
""")
183+
end
184+
end
185+
186+
defp configured_callbacks do
187+
[
188+
# Hardcoded for now
189+
Phoenix.LiveView.ColocatedJS,
190+
Phoenix.LiveView.ColocatedCSS
191+
]
192+
end
193+
194+
defp global_settings do
195+
Application.get_env(
196+
:phoenix_live_view,
197+
:colocated_assets,
198+
Application.get_env(:phoenix_live_view, :colocated_js, [])
199+
)
200+
end
201+
202+
defp project_settings do
203+
lv_config =
204+
Mix.Project.config()
205+
|> Keyword.get(:phoenix_live_view, [])
206+
207+
Keyword.get_lazy(lv_config, :colocated_assets, fn ->
208+
Keyword.get(lv_config, :colocated_js, [])
209+
end)
210+
end
211+
212+
defp target_dir do
213+
app = to_string(Mix.Project.config()[:app])
214+
default = Path.join(Mix.Project.build_path(), "phoenix-colocated")
215+
216+
global_settings()
217+
|> Keyword.get(:target_directory, default)
218+
|> Path.join(app)
219+
end
220+
221+
defp subdirectories(path) do
222+
Path.wildcard(Path.join(path, "*")) |> Enum.filter(&File.dir?(&1))
223+
end
224+
225+
defp warn_for_outdated_config! do
226+
case Application.get_env(:phoenix_live_view, :colocated_js) do
227+
nil ->
228+
:ok
229+
230+
_ ->
231+
IO.warn("""
232+
The :colocated_js configuration option is deprecated!
233+
234+
Instead of
235+
236+
config :phoenix_live_view, :colocated_js, ...
237+
238+
use
239+
240+
config :phoenix_live_view, :colocated_assets, ...
241+
242+
""")
243+
end
244+
245+
lv_config =
246+
Mix.Project.config()
247+
|> Keyword.get(:phoenix_live_view, [])
248+
249+
case Keyword.get(lv_config, :colocated_js) do
250+
nil ->
251+
:ok
252+
253+
_ ->
254+
IO.warn("""
255+
The :colocated_js configuration option is deprecated!
256+
257+
Instead of
258+
259+
[
260+
...,
261+
phoenix_live_view: [colocated_js: ...]
262+
]
263+
264+
use
265+
266+
[
267+
...,
268+
phoenix_live_view: [colocated_assets: ...]
269+
]
270+
271+
in your mix.exs project configuration.
272+
""")
273+
end
274+
end
275+
end

0 commit comments

Comments
 (0)