Packages
surface
0.7.4
0.12.3
0.12.2
0.12.1
0.12.0
0.11.5
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.1
0.2.0
0.1.1
0.1.0
0.1.0-alpha.2
0.1.0-alpha.1
0.1.0-alpha.0
A component based library for Phoenix LiveView
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/compile/surface.asset_generator.ex
defmodule Mix.Tasks.Compile.Surface.AssetGenerator do
@moduledoc false
alias Mix.Task.Compiler.Diagnostic
@default_hooks_output_dir "assets/js/_hooks"
@supported_hooks_extensions ~W"js jsx ts tsx" |> Enum.join(",")
@hooks_tag ".hooks"
@hooks_extension "#{@hooks_tag}.{#{@supported_hooks_extensions}}"
def run() do
{js_files, _css_files, diagnostics} = get_colocated_assets()
generate_files(js_files)
diagnostics |> Enum.reject(&is_nil/1)
end
def generate_files(js_files) do
opts = Application.get_env(:surface, :compiler, [])
hooks_output_dir = Keyword.get(opts, :hooks_output_dir, @default_hooks_output_dir)
js_output_dir = Path.join([File.cwd!(), hooks_output_dir])
index_file = Path.join([js_output_dir, "index.js"])
File.mkdir_p!(js_output_dir)
unused_hooks_files = delete_unused_hooks_files!(js_output_dir, js_files)
index_file_time =
case File.stat(index_file) do
{:ok, %File.Stat{mtime: time}} -> time
_ -> nil
end
update_index? =
for {src_file, dest_file_name} <- js_files,
dest_file = Path.join(js_output_dir, dest_file_name),
{:ok, %File.Stat{mtime: time}} <- [File.stat(src_file)],
!File.exists?(dest_file) or time > index_file_time,
reduce: false do
_ ->
content = [header(), "\n\n", File.read!(src_file)]
File.write!(dest_file, content)
true
end
if !index_file_time or update_index? or unused_hooks_files != [] do
File.write!(index_file, index_content(js_files))
end
end
defp get_colocated_assets() do
for mod <- components(), module_loaded?(mod), reduce: {[], [], []} do
{js_files, css_files, diagnostics} ->
component_file = mod.module_info() |> get_in([:compile, :source]) |> to_string()
base_file = component_file |> Path.rootname()
base_name = inspect(mod)
{js_file, new_diagnostic} = js_file(base_name, base_file, component_file)
js_files =
if js_file != nil do
dest_js_file = "#{base_name}#{@hooks_tag}#{Path.extname(js_file)}"
[{js_file, dest_js_file} | js_files]
else
js_files
end
css_file = "#{base_file}.css"
dest_css_file = "#{base_name}.css"
css_files = if File.exists?(css_file), do: [{css_file, dest_css_file} | css_files], else: css_files
{js_files, css_files, [new_diagnostic | diagnostics]}
end
end
defp js_file(base_name, base_file, component_file) do
hooks_files = Path.wildcard("#{base_file}#{@hooks_extension}") |> Enum.sort()
hooks_count = length(hooks_files)
diagnostic =
if hooks_count > 1 do
file_messages =
hooks_files
|> Enum.map(&"* `#{Path.relative_to_cwd(&1)}`")
message = """
component `#{base_name}` has #{hooks_count} hooks files, using the first one
#{Enum.join(file_messages, "\n ")}
"""
warning(message, component_file, 1)
end
{List.first(hooks_files), diagnostic}
end
defp index_content([]) do
"""
#{header()}
export default {}
"""
end
defp index_content(js_files) do
files = js_files |> Enum.sort() |> Enum.with_index(1)
{hooks, imports} =
for {{_file, dest_file}, index} <- files, reduce: {[], []} do
{hooks, imports} ->
namespace = Regex.replace(~r/#{@hooks_tag}.*$/, dest_file, "")
var = "c#{index}"
hook = ~s[ns(#{var}, "#{namespace}")]
imp = ~s[import * as #{var} from "./#{namespace}.hooks"]
{[hook | hooks], [imp | imports]}
end
hooks = Enum.reverse(hooks)
imports = Enum.reverse(imports)
"""
#{header()}
function ns(hooks, nameSpace) {
const updatedHooks = {}
Object.keys(hooks).map(function(key) {
updatedHooks[`${nameSpace}#${key}`] = hooks[key]
})
return updatedHooks
}
#{Enum.join(imports, "\n")}
let hooks = Object.assign(
#{Enum.join(hooks, ",\n ")}
)
export default hooks
"""
end
defp delete_unused_hooks_files!(js_output_dir, js_files) do
used_files = Enum.map(js_files, fn {_, dest_file} -> Path.join(js_output_dir, dest_file) end)
all_files =
js_output_dir
|> Path.join("*#{@hooks_extension}")
|> Path.wildcard()
unsused_files = all_files -- used_files
Enum.each(unsused_files, &File.rm!/1)
unsused_files
end
defp components() do
project_app = Mix.Project.config()[:app]
:ok = Application.ensure_loaded(project_app)
project_deps_apps = Application.spec(project_app, :applications) || []
opts = Application.get_env(:surface, :compiler, [])
only_web_namespace = Keyword.get(opts, :only_web_namespace, false)
for app <- [project_app | project_deps_apps],
deps_apps = Application.spec(app)[:applications] || [],
app in [:surface, project_app] or :surface in deps_apps,
prefix = app_beams_prefix(app, project_app, only_web_namespace),
{dir, files} = app_beams_dir_and_files(app),
file <- files,
List.starts_with?(file, prefix) do
:filename.join(dir, file)
end
|> Enum.chunk_every(50)
|> Task.async_stream(fn files ->
for file <- files,
{:ok, {_, [{_, chunk} | _]}} = :beam_lib.chunks(file, ['Attr']),
chunk |> :erlang.binary_to_term() |> Keyword.get(:component_type) do
file |> Path.basename(".beam") |> String.to_atom()
end
end)
|> Enum.flat_map(fn {:ok, result} -> result end)
end
defp app_beams_prefix(app, project_app, only_web_namespace) do
if only_web_namespace and app == project_app do
Mix.Phoenix.base()
|> Mix.Phoenix.web_module()
|> Module.concat(".")
|> to_charlist()
else
'Elixir.'
end
end
defp app_beams_dir_and_files(app) do
dir =
app
|> Application.app_dir()
|> Path.join("ebin")
|> String.to_charlist()
{:ok, files} = :file.list_dir(dir)
{dir, files}
end
defp module_loaded?(module) do
match?({:module, _mod}, Code.ensure_compiled(module))
end
defp header() do
"""
/*
This file was generated by the Surface compiler.
*/\
"""
end
defp warning(message, file, line) do
# TODO: Provide column information in diagnostic once we depend on Elixir v1.13+
%Diagnostic{
message: message,
file: file,
position: line,
severity: :warning,
compiler_name: "Surface"
}
end
end