defmodule Mix.Tasks.Compile.Surface do @moduledoc """ Generate CSS and JS assets for components. """ use Mix.Task @recursive true @default_hooks_output_dir "assets/js/_hooks" @hooks_extension ".hooks.js" @doc false def run(_args) do case get_colocated_assets() |> generate_files() do "" -> {:noop, []} _ -> {:ok, []} end end @doc false def generate_files({js_files, _css_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 [app] <- applications(), mod <- app_modules(app), module_loaded?(mod), function_exported?(mod, :component_type, 0), reduce: {[], []} do {js_files, css_files} -> base_file = mod.module_info() |> get_in([:compile, :source]) |> Path.rootname() js_file = "#{base_file}#{@hooks_extension}" base_name = inspect(mod) dest_js_file = "#{base_name}#{@hooks_extension}" css_file = "#{base_file}.css" dest_css_file = "#{base_name}.css" js_files = if File.exists?(js_file), do: [{js_file, dest_js_file} | js_files], else: js_files css_files = if File.exists?(css_file), do: [{css_file, dest_css_file} | css_files], else: css_files {js_files, css_files} end 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 = Path.basename(dest_file, @hooks_extension) 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 app_modules(app) do if Application.ensure_loaded(app) == :ok do app |> Application.app_dir() |> Path.join("ebin/Elixir.*.beam") |> Path.wildcard() |> Enum.map(&beam_to_module/1) else [] end end defp beam_to_module(path) do path |> Path.basename(".beam") |> String.to_atom() end defp applications do # If we invoke :application.loaded_applications/0, # it can error if we don't call safe_fixtable before. # Since in both cases we are reaching over the # application controller internals, we choose to match # for performance. apps = :ets.match(:ac_tab, {{:loaded, :"$1"}, :_}) # Make sure we have the project's app (it might not be there when first compiled) apps = [[Mix.Project.config()[:app]] | apps] apps |> MapSet.new() |> MapSet.to_list() 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 end