Skip to content

Commit 0712267

Browse files
committed
colocated abstraction
1 parent 9a3f3d0 commit 0712267

12 files changed

Lines changed: 359 additions & 379 deletions

File tree

lib/mix/tasks/compile/phoenix_live_view.ex

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
2929
end
3030

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

0 commit comments

Comments
 (0)