diff --git a/config/e2e.exs b/config/e2e.exs index 33e9a3fe20..349e351e7e 100644 --- a/config/e2e.exs +++ b/config/e2e.exs @@ -1,3 +1,5 @@ import Config config :logger, :level, :error + +config :phoenix_live_view, :root_tag_attribute, "phx-r" diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex index 957698d013..1269de6d40 100644 --- a/lib/mix/tasks/compile/phoenix_live_view.ex +++ b/lib/mix/tasks/compile/phoenix_live_view.ex @@ -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: @@ -29,6 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do end defp compile do - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() end end diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex index a7cf70ec59..5a04f601c0 100644 --- a/lib/phoenix_component/macro_component.ex +++ b/lib/phoenix_component/macro_component.ex @@ -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 diff --git a/lib/phoenix_live_view/colocated_assets.ex b/lib/phoenix_live_view/colocated_assets.ex new file mode 100644 index 0000000000..cbbe441cfb --- /dev/null +++ b/lib/phoenix_live_view/colocated_assets.ex @@ -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 + 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 + ] + 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 + 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 project configuration. + """) + end + end +end diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex new file mode 100644 index 0000000000..56df2f9a89 --- /dev/null +++ b/lib/phoenix_live_view/colocated_css.ex @@ -0,0 +1,325 @@ +defmodule Phoenix.LiveView.ColocatedCSS do + @moduledoc ~S''' + Building blocks for a special HEEx `:type` that extracts any CSS styles + from a colocated ` + ``` + + into + + ```css + @scope ([phx-css-abc123]) to ([phx-r]) { + .my-class { color: red; } + } + ``` + + and if `lower-bound` is set to `inclusive`, it transforms it into + + ```css + @scope ([phx-css-abc123]) to ([phx-r] > *) { + .my-class { color: red; } + } + ``` + + This applies any styles defined in the colocated CSS block to any element between a local root and a component. + It relies on LiveView's global `:root_tag_attribute`, which is an attribute that LiveView adds to all root tags, + no matter if colocated CSS is used or not. When the browser encounters a `phx-r` attribute, which in this case + is assumed to be the configured global `:root_tag_attribute`, it stops the scoped CSS rule. + + Another way to implement scoped CSS could be to use PostCSS and apply an attribute to all tags in a template. + ''' + + @doc """ + Callback invoked for each colocated CSS tag. + + The callback receives the tag name, the string attributes and a map of metadata. + + For example, for the following tag: + + ```heex + + ``` + + The callback would receive the following arguments: + + * tag_name: `"style"` + * attrs: %{"data-scope" => "my-scope"} + * meta: `%{file: "path/to/file.ex", module: MyApp.MyModule, line: 10}` + + The callback must return either `{:ok, scoped_css, directives}` or `{:error, reason}`. + If an error is returned, it will be logged and the CSS will not be extracted. + + The `directives` needs to be a keyword list that supports the following options: + + * `root_tag_attribute`: A `{key, value}` tuple that will be added as + an attribute to all "root tags" of the template defining the scoped CSS tag. + See the section on root tags below for more information. + * `tag_attribute`: A `{key, value}` tuple that will be added as an attribute to + all HTML tags in the template defining the scoped CSS tag. + + ## Root tags + + In a HEEx template, all outermost tags are considered "root tags" and are + affected by the `root_tag_attribute` directive. If a template uses components, + the slots of those components are considered as root tags as well. + + Here's an example showing which elements would be considered root tags: + + ```heex +
<---- root tag + Hello <---- not a root tag + + <.my_component> +

World

<---- root tag + +
+ + <.my_component> + World <---- root tag + + <:a_named_slot> +
<---- root tag + Foo +

Bar

<---- not a root tag +
+ + + ``` + """ + @callback transform(tag_name :: binary(), attrs :: map(), css :: binary(), meta :: map()) :: + {:ok, binary(), keyword()} | {:error, term()} + + defmacro __using__(_) do + # implements the MacroComponent behaviour + # but we don't add @behaviour to prevent users to need to differentiate + # @impl true for the ColocatedCSS behaviour itself + quote do + @behaviour unquote(__MODULE__) + + def transform(ast, meta) do + Phoenix.LiveView.ColocatedCSS.__transform__(ast, meta, __MODULE__) + end + end + end + + @behaviour Phoenix.LiveView.ColocatedAssets + + @doc false + def __transform__({"style", attributes, [text_content], _tag_meta} = _ast, meta, module) do + validate_phx_version!() + + opts = Map.new(attributes) + + case extract(opts, text_content, meta, module) do + {data, directives} -> + # we always drop colocated CSS from the rendered output + {:ok, "", data, directives} + + nil -> + {:ok, ""} + end + end + + def __transform__(_ast, _meta, _module) do + raise ArgumentError, "ColocatedCSS can only be used on style tags" + end + + defp validate_phx_version! do + phoenix_version = to_string(Application.spec(:phoenix, :vsn)) + + if not Version.match?(phoenix_version, "~> 1.8.0") do + raise ArgumentError, ~s|ColocatedCSS requires at least {:phoenix, "~> 1.8.0"}| + end + end + + defp extract(opts, text_content, meta, module) do + transform_meta = %{ + module: meta.env.module, + file: meta.env.file, + line: meta.env.line + } + + case module.transform("style", opts, text_content, transform_meta) do + {:ok, styles, directives} when is_binary(styles) and is_list(directives) -> + filename = "#{meta.env.line}_#{hash(styles)}.css" + + data = + Phoenix.LiveView.ColocatedAssets.extract( + __MODULE__, + meta.env.module, + filename, + styles, + nil + ) + + {data, directives} + + {:error, reason} -> + IO.warn( + "ColocatedCSS module #{inspect(module)} returned an error, skipping: #{inspect(reason)}" + ) + + nil + + other -> + raise ArgumentError, + "expected the ColocatedCSS implementation to return {:ok, scoped_css, directives} or {:error, term}, got: #{inspect(other)}" + end + end + + defp hash(string) do + string + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode32(case: :lower, padding: false) + end + + @impl Phoenix.LiveView.ColocatedAssets + def build_manifests(files) do + if files == [] do + [{"colocated.css", ""}] + else + [ + {"colocated.css", + Enum.reduce(files, [], fn %{relative_path: file}, acc -> + line = ~s[@import "./#{file}";\n] + [acc | line] + end)} + ] + end + end +end diff --git a/lib/phoenix_live_view/colocated_js.ex b/lib/phoenix_live_view/colocated_js.ex index 5f06ea1cdf..81fffd2cd6 100644 --- a/lib/phoenix_live_view/colocated_js.ex +++ b/lib/phoenix_live_view/colocated_js.ex @@ -180,10 +180,9 @@ defmodule Phoenix.LiveView.ColocatedJS do ''' @behaviour Phoenix.Component.MacroComponent + @behaviour Phoenix.LiveView.ColocatedAssets - alias Phoenix.Component.MacroComponent - - @impl true + @impl Phoenix.Component.MacroComponent def transform({"script", attributes, [text_content], _tag_meta} = _ast, meta) do validate_phx_version!() @@ -227,11 +226,6 @@ defmodule Phoenix.LiveView.ColocatedJS do @doc false def extract(opts, text_content, meta) do - # _build/dev/phoenix-colocated/otp_app/MyApp.MyComponent/line_no.js - target_path = - target_dir() - |> Path.join(inspect(meta.env.module)) - filename_opts = %{name: opts["name"]} |> maybe_put_opt(opts, "key", :key) @@ -244,10 +238,13 @@ defmodule Phoenix.LiveView.ColocatedJS do filename = "#{meta.env.line}_#{hashed_name}.#{opts["extension"] || "js"}" - File.mkdir_p!(target_path) - File.write!(Path.join(target_path, filename), text_content) - - {filename, filename_opts} + Phoenix.LiveView.ColocatedAssets.extract( + __MODULE__, + meta.env.module, + filename, + text_content, + filename_opts + ) end defp maybe_put_opt(map, opts, opts_key, target_key) do @@ -260,109 +257,41 @@ defmodule Phoenix.LiveView.ColocatedJS do end 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 hooks - clear_manifests!() - files = clear_outdated_and_get_files!() - write_new_manifests!(files) - 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) - - Enum.flat_map(modules, fn module_folder -> - module = Module.concat([Path.basename(module_folder)]) - process_module(module_folder, module) - end) - end - - defp process_module(module_folder, module) do - with true <- Code.ensure_loaded?(module), - data when data != [] <- get_data(module) do - expected_files = Enum.map(data, fn {filename, _opts} -> 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(data, fn {filename, config} -> - absolute_file_path = Path.join(module_folder, filename) - {absolute_file_path, config} - end) - else - _ -> - # either the module does not exist any more or - # does not have any colocated hooks / JS - File.rm_rf!(module_folder) - [] - end - end - - defp get_data(module) do - hooks_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedHook) - js_data = MacroComponent.get_data(module, Phoenix.LiveView.ColocatedJS) - - hooks_data ++ js_data - end - - defp write_new_manifests!(files) do + @impl Phoenix.LiveView.ColocatedAssets + def build_manifests(files) do if files == [] do - File.mkdir_p!(target_dir()) - - File.write!( - Path.join(target_dir(), "index.js"), - "export const hooks = {};\nexport default {};" - ) + [{"index.js", "export const hooks = {};\nexport default {};"}] else files - |> Enum.group_by(fn {_file, config} -> + |> Enum.group_by(fn %Phoenix.LiveView.ColocatedAssets{data: config} -> config[:manifest] || "index.js" end) - |> Enum.each(fn {manifest, entries} -> - write_manifest(manifest, entries) + |> Enum.map(fn {manifest, entries} -> + build_manifest(manifest, entries) end) end end - defp write_manifest(manifest, entries) do - target_dir = target_dir() - + defp build_manifest(manifest, entries) do content = entries - |> Enum.group_by(fn {_file, config} -> config[:key] || :default end) + |> Enum.group_by(fn %{data: config} -> config[:key] || :default end) |> Enum.reduce(["const js = {}; export default js;\n"], fn group, acc -> case group do {:default, entries} -> [ acc, Enum.map(entries, fn - {file, %{name: nil}} -> - ~s[import "./#{Path.relative_to(file, target_dir)}";\n] + %{relative_path: file, data: %{name: nil}} -> + ~s[import "./#{file}";\n] - {file, %{name: name}} -> + %{relative_path: file, data: %{name: name}} -> import_name = "js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false) escaped_name = Phoenix.HTML.javascript_escape(name) - ~s + ~s end) ] @@ -373,89 +302,21 @@ defmodule Phoenix.LiveView.ColocatedJS do acc, ~s, Enum.map(entries, fn - {file, %{name: nil}} -> - ~s[import "./#{Path.relative_to(file, target_dir)}";\n] + %{relative_path: file, data: %{name: nil}} -> + ~s[import "./#{file}";\n] - {file, %{name: name}} -> + %{relative_path: file, data: %{name: name}} -> import_name = "js_" <> Base.encode32(:crypto.hash(:md5, file), case: :lower, padding: false) escaped_name = Phoenix.HTML.javascript_escape(name) - ~s + ~s end) ] end end) - File.write!(Path.join(target_dir, manifest), content) - end - - defp maybe_link_node_modules! do - 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 or you don't need to import files from "assets/node_modules" - in your hooks, you can simply disable this warning by setting - - config :phoenix_live_view, :colocated_js, - disable_symlink_warning: true - """ - - IO.warn(""" - Failed to symlink node_modules folder for Phoenix.LiveView.ColocatedJS: #{inspect(reason)} - - 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 global_settings do - Application.get_env(:phoenix_live_view, :colocated_js, []) - end - - defp project_settings do - Mix.Project.config() - |> Keyword.get(:phoenix_live_view, []) - |> Keyword.get(:colocated_js, []) - end - - defp target_dir do - default = Path.join(Mix.Project.build_path(), "phoenix-colocated") - app = to_string(Mix.Project.config()[:app]) - - 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)) + {manifest, content} end end diff --git a/lib/phoenix_live_view/tag_engine/compiler.ex b/lib/phoenix_live_view/tag_engine/compiler.ex index de30255caf..a826771e70 100644 --- a/lib/phoenix_live_view/tag_engine/compiler.ex +++ b/lib/phoenix_live_view/tag_engine/compiler.ex @@ -27,6 +27,7 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do caller: Keyword.fetch!(opts, :caller), source: Keyword.fetch!(opts, :source), tag_handler: tag_handler, + tag_attributes: Keyword.get_values(directives, :tag_attribute), root_tag_attribute: Application.get_env(:phoenix_live_view, :root_tag_attribute), root_tag_attributes: Keyword.get_values(directives, :root_tag_attribute), # The following keys are updated when traversing nodes @@ -486,6 +487,7 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do text = "<#{name}" |> maybe_add_phx_loc(state, meta) + |> maybe_add_tag_attributes(state, meta) |> maybe_add_root_tag_attributes(state, meta) substate = state.engine.handle_text(substate, meta, text) @@ -501,6 +503,22 @@ defmodule Phoenix.LiveView.TagEngine.Compiler do end end + defp maybe_add_tag_attributes(text, state, _meta) do + case state do + %{tag_attributes: [_ | _] = attributes} -> + attrs = + attributes + |> Phoenix.HTML.attributes_escape() + |> Phoenix.HTML.safe_to_string() + + # Phoenix.HTML.attributes_escape/1 adds a leading space automatically + "#{text}#{attrs}" + + _ -> + text + end + end + defp maybe_add_root_tag_attributes(text, %{local_root?: true} = state, _meta) do case state do %{root_tag_attribute: root_tag_attribute} when is_binary(root_tag_attribute) -> diff --git a/lib/phoenix_live_view/tag_engine/parser.ex b/lib/phoenix_live_view/tag_engine/parser.ex index 7da8bd97eb..fb692899e9 100644 --- a/lib/phoenix_live_view/tag_engine/parser.ex +++ b/lib/phoenix_live_view/tag_engine/parser.ex @@ -694,17 +694,20 @@ defmodule Phoenix.LiveView.TagEngine.Parser do directives end - defp validate_directive!(_module, :root_tag_attribute, nil, _), do: :ok + defp validate_directive!(_module, type, nil, _) + when type in [:tag_attribute, :root_tag_attribute], do: :ok - defp validate_directive!(_module, :root_tag_attribute, {name, value}, _meta) - when is_binary(name) and (is_binary(value) or value == true) do + defp validate_directive!(_module, type, {name, value}, _meta) + when type in [:tag_attribute, :root_tag_attribute] and + is_binary(name) and (is_binary(value) or value == true) do :ok end - defp validate_directive!(module, :root_tag_attribute, other, meta) do + defp validate_directive!(module, type, other, meta) + when type in [:tag_attribute, :root_tag_attribute] do throw_syntax_error!( """ - expected {name, value} for :root_tag_attribute directive from macro component #{inspect(module)}, got: #{inspect(other)} + expected {name, value} for :#{type} directive from macro component #{inspect(module)}, got: #{inspect(other)} name must be a compile-time string, and value must be a compile-time string or true """, diff --git a/test/e2e/support/colocated_live.ex b/test/e2e/support/colocated_live.ex index e9fc0aa2f0..d79c67872f 100644 --- a/test/e2e/support/colocated_live.ex +++ b/test/e2e/support/colocated_live.ex @@ -35,6 +35,8 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do end alias Phoenix.LiveView.ColocatedHook, as: Hook + alias Phoenix.LiveViewTest.Support.ColocatedScopedCSS, as: ScopedCSS + alias Phoenix.LiveViewTest.Support.ColocatedGlobalCSS, as: GlobalCSS alias Phoenix.LiveView.JS def mount(_params, _session, socket) do @@ -71,6 +73,7 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do // initialize js exec handler from colocated js colocated.js_exec(liveSocket); + {@inner_content} """ @@ -157,6 +160,13 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do end + <.global_colocated_css /> +

Should have red background

+ <.scoped_colocated_css /> +

Should have no background (out of scope)

+ <.scoped_inclusive_lower_bound_colocated_css /> + <.scoped_exclusive_lower_bound_colocated_css /> + <.lv_code_sample /> """ end @@ -190,4 +200,155 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do ''' end + + defp global_colocated_css(assigns) do + ~H""" + + """ + end + + defp scoped_colocated_css(assigns) do + ~H""" + +
+ Should have blue background + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_one> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_inner_block_two> + + Should have no background (scope root) + + Should have blue background + + + + <.scoped_css_slot_one> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + + <.scoped_css_slot_two> + <:test> + + Should have no background (scope root) + + Should have blue background + + + + +
+ """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_one(assigns) do + ~H""" + {render_slot(@inner_block)} + """ + end + + slot :inner_block, required: true + + defp scoped_css_inner_block_two(assigns) do + ~H""" + +
+ Should have yellow background + {render_slot(@inner_block)} +
+ """ + end + + slot :test, required: true + + defp scoped_css_slot_one(assigns) do + ~H""" + {render_slot(@test)} + """ + end + + slot :test, required: true + + defp scoped_css_slot_two(assigns) do + ~H""" + +
+ Should have green background + {render_slot(@test)} +
+ """ + end + + defp scoped_exclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={false} /> +
+ """ + end + + defp scoped_inclusive_lower_bound_colocated_css(assigns) do + ~H""" + +
+ <.flex_items should_flex?={true} /> +
+ """ + end + + attr :should_flex?, :boolean, required: true + + defp flex_items(assigns) do + ~H""" +

+ {if @should_flex?, do: "Should", else: "Shouldn't"} Flex {x} +

+ """ + end end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index e7fd9346f5..704db593d2 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -322,8 +322,8 @@ end IO.puts("Starting e2e server on port #{Phoenix.LiveViewTest.E2E.Endpoint.config(:http)[:port]}") -# we need to manually compile the colocated hooks / js -Phoenix.LiveView.ColocatedJS.compile() +# we need to manually compile the colocated hooks / js and css +Phoenix.LiveView.ColocatedAssets.compile() if not IEx.started?() do # when running the test server manually, we halt after diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js index b0d6e1c672..fbf8dcf529 100644 --- a/test/e2e/tests/colocated.spec.js +++ b/test/e2e/tests/colocated.spec.js @@ -41,3 +41,98 @@ test("custom macro component works (syntax highlighting)", async ({ page }) => { page.locator("pre").nth(1).getByText("@temperature"), ).toHaveClass("na"); }); + +test("global colocated css works", async ({ page }) => { + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="global"]')).toHaveCSS( + "background-color", + "rgb(255, 0, 0)", + ); +}); + +test("scoped colocated css works", async ({ page, browserName }) => { + // TODO: revisit when + // https://bugzilla.mozilla.org/show_bug.cgi?id=1980526 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1914188 + // are fixed. + test.skip(browserName === "firefox", "Currently broken"); + + await page.goto("/colocated"); + await syncLV(page); + + await expect(page.locator('[data-test="scoped"]')).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + + const blueLocator = page.locator('[data-test-scoped="blue"]'); + + await expect(blueLocator).toHaveCount(6); + + for (const shouldBeBlue of await blueLocator.all()) { + await expect(shouldBeBlue).toHaveCSS("background-color", "rgb(0, 0, 255)"); + } + + const noneLocator = page.locator('[data-test-scoped="none"]'); + + await expect(noneLocator).toHaveCount(5); + + for (const shouldBeTransparent of await noneLocator.all()) { + await expect(shouldBeTransparent).toHaveCSS( + "background-color", + "rgba(0, 0, 0, 0)", + ); + } + + await expect(page.locator('[data-test-scoped="yellow"]')).toHaveCSS( + "background-color", + "rgb(255, 255, 0)", + ); + + await expect(page.locator('[data-test-scoped="green"]')).toHaveCSS( + "background-color", + "rgb(0, 255, 0)", + ); +}); + +test("scoped colocated css lower bound inclusive/exclusive works", async ({ + page, + browserName, +}) => { + // TODO: revisit when + // https://bugzilla.mozilla.org/show_bug.cgi?id=1980526 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1914188 + // are fixed. + test.skip(browserName === "firefox", "Currently broken"); + + await page.goto("/colocated"); + await syncLV(page); + + const lowerBoundContainerLocator = page.locator( + "[data-test-lower-bound-container]", + ); + + await expect(lowerBoundContainerLocator).toHaveCount(2); + + for (const shouldBeFlex of await lowerBoundContainerLocator.all()) { + await expect(shouldBeFlex).toHaveCSS("display", "flex"); + } + + const inclusiveFlexItemsLocator = page.locator('[data-test-inclusive="yes"]'); + + await expect(inclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldFlex of await inclusiveFlexItemsLocator.all()) { + await expect(shouldFlex).toHaveCSS("flex", "1 1 0%"); + } + + const exclusiveFlexItemsLocator = page.locator('[data-test-inclusive="no"]'); + + await expect(exclusiveFlexItemsLocator).toHaveCount(3); + + for (const shouldntFlex of await exclusiveFlexItemsLocator.all()) { + await expect(shouldntFlex).toHaveCSS("flex", "0 1 auto"); + } +}); diff --git a/test/phoenix_component/macro_component_integration_test.exs b/test/phoenix_component/macro_component_integration_test.exs index 3752bd43d5..2253ac404d 100644 --- a/test/phoenix_component/macro_component_integration_test.exs +++ b/test/phoenix_component/macro_component_integration_test.exs @@ -340,7 +340,7 @@ defmodule Phoenix.Component.MacroComponentIntegrationTest do end end - assert data = MacroComponent.get_data(TestComponentWithData1, MyMacroComponent) + assert %{MyMacroComponent => data} = MacroComponent.get_data(TestComponentWithData1) assert length(data) == 2 assert Enum.find(data, fn %{opts: opts} -> opts == %{"baz" => nil, "foo" => "bar"} end) diff --git a/test/phoenix_component/macro_component_test.exs b/test/phoenix_component/macro_component_test.exs index 80bd9cd77b..d5dbba887b 100644 --- a/test/phoenix_component/macro_component_test.exs +++ b/test/phoenix_component/macro_component_test.exs @@ -63,19 +63,19 @@ defmodule Phoenix.Component.MacroComponentTest do end end - describe "get_data/2" do - test "returns an empty list if the component module does not exist" do - assert MacroComponent.get_data(IDoNotExist, MyMacroComponent) == [] + describe "get_data/1" do + test "returns an empty map if the component module does not exist" do + assert MacroComponent.get_data(IDoNotExist) == %{} end - test "returns an empty list if the component does not define any macro components" do + test "returns an empty map if the component does not define any macro components" do defmodule MyComponent do use Phoenix.Component def render(assigns), do: ~H"" end - assert MacroComponent.get_data(MyComponent, MyMacroComponent) == [] + assert MacroComponent.get_data(MyComponent) == %{} end end end diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs new file mode 100644 index 0000000000..79d7373e61 --- /dev/null +++ b/test/phoenix_live_view/colocated_css_test.exs @@ -0,0 +1,237 @@ +defmodule Phoenix.LiveView.ColocatedCSSTest do + # we set async: false because we call the colocated CSS compiler + # and it reads / writes to a shared folder, and also because + # we manipulate the Application env for :root_tag_attribute + use ExUnit.Case, async: false + + alias Phoenix.LiveView.TagEngine.Tokenizer.ParseError + + setup do + Application.put_env(:phoenix_live_view, :root_tag_attribute, "phx-r") + on_exit(fn -> Application.delete_env(:phoenix_live_view, :root_tag_attribute) end) + end + + describe "global styles" do + test "are extracted and available under manifest import" do + defmodule TestGlobalComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestGlobalComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" + ) + ) + + assert File.read!(style) == "\n .sample-class { background-color: #FFFFFF; }\n" + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedAssets.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :code.delete(__MODULE__.TestGlobalComponent) + :code.purge(__MODULE__.TestGlobalComponent) + end + end + + describe "scoped styles" do + test "with exclusive (default) lower-bound is extracted and available under manifest import" do + defmodule TestScopedExclusiveComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedExclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/\[phx-css-.+?\]/, file_contents, ~s|[phx-css-SCOPE_HERE]|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r]) { \n .sample-class { background-color: #FFFFFF; }\n }| + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedAssets.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :code.delete(__MODULE__.TestScopedExclusiveComponent) + :code.purge(__MODULE__.TestScopedExclusiveComponent) + end + + test "with inclusive lower-bound is extracted and available under manifest import" do + defmodule TestScopedInclusiveComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + + assert module_folders = + File.ls!( + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view") + ) + + assert folder = + Enum.find(module_folders, fn folder -> + folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedInclusiveComponent$/ + end) + + assert [style] = + Path.wildcard( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/#{folder}/*.css" + ) + ) + + file_contents = File.read!(style) + + file_contents = + Regex.replace(~r/\[phx-css-.+?\]/, file_contents, ~s|[phx-css-SCOPE_HERE]|) + + # The scope is a generated value, so for testing reliability we just replace it with a known + # value to assert against. + assert file_contents == + ~s|@scope ([phx-css-SCOPE_HERE]) to ([phx-r] > *) { \n .sample-class { background-color: #FFFFFF; }\n }| + + # now write the manifest manually as we are in a test + Phoenix.LiveView.ColocatedAssets.compile() + + assert manifest = + File.read!( + Path.join( + Mix.Project.build_path(), + "phoenix-colocated/phoenix_live_view/colocated.css" + ) + ) + + path = + Path.relative_to( + style, + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/") + ) + + # style is in manifest + assert manifest =~ ~s[@import "./#{path}";\n] + after + :code.delete(__MODULE__.TestScopedInclusiveComponent) + :code.purge(__MODULE__.TestScopedInclusiveComponent) + end + + test "raises for invalid lower-bound attribute value" do + message = + ~r/expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: "unknown"/ + + assert_raise ParseError, + message, + fn -> + defmodule TestBadLowerBoundAttrComponent do + use Phoenix.Component + + def fun(assigns) do + ~H""" + + """ + end + end + end + after + :code.delete(__MODULE__.TestBadLowerBoundAttrComponent) + :code.purge(__MODULE__.TestBadLowerBoundAttrComponent) + end + end + + test "writes empty manifest when no colocated styles exist" do + manifest = + Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/colocated.css") + + Phoenix.LiveView.ColocatedAssets.compile() + assert File.exists?(manifest) + assert File.read!(manifest) == "" + end +end diff --git a/test/phoenix_live_view/colocated_hook_test.exs b/test/phoenix_live_view/colocated_hook_test.exs index 18aa814905..191d3e6611 100644 --- a/test/phoenix_live_view/colocated_hook_test.exs +++ b/test/phoenix_live_view/colocated_hook_test.exs @@ -42,7 +42,7 @@ defmodule Phoenix.LiveView.ColocatedHookTest do assert File.read!(script) =~ "Hello, world!" # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( diff --git a/test/phoenix_live_view/colocated_js_test.exs b/test/phoenix_live_view/colocated_js_test.exs index 61e124ec1e..bf85a82752 100644 --- a/test/phoenix_live_view/colocated_js_test.exs +++ b/test/phoenix_live_view/colocated_js_test.exs @@ -43,7 +43,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert manifest = File.read!( @@ -104,7 +104,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() relative_script_path = Path.relative_to( @@ -168,7 +168,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do """ # now write the manifest manually as we are in a test - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() relative_script_path = Path.relative_to( @@ -214,7 +214,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do test "writes empty index.js when no colocated scripts exist" do manifest = Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view/index.js") - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() assert File.exists?(manifest) assert File.read!(manifest) == "export const hooks = {};\nexport default {};" end @@ -227,7 +227,7 @@ defmodule Phoenix.LiveView.ColocatedJSTest do end File.mkdir_p!(Path.join(node_path, "foo")) - Phoenix.LiveView.ColocatedJS.compile() + Phoenix.LiveView.ColocatedAssets.compile() symlink = Path.join( diff --git a/test/support/colocated_css.ex b/test/support/colocated_css.ex new file mode 100644 index 0000000000..686b320c88 --- /dev/null +++ b/test/support/colocated_css.ex @@ -0,0 +1,87 @@ +defmodule Phoenix.LiveViewTest.Support.ColocatedScopedCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", attrs, css, meta) do + validate_opts!(attrs) + + {scope, css} = do_scope(css, attrs, meta) + + {:ok, css, [root_tag_attribute: {"phx-css-#{scope}", true}]} + end + + defp validate_opts!(opts) do + Enum.each(opts, fn {key, val} -> validate_opt!({key, val}, Map.delete(opts, key)) end) + end + + defp validate_opt!({"lower-bound", val}, _other_opts) when val in ["inclusive", "exclusive"] do + :ok + end + + defp validate_opt!({"lower-bound", val}, _other_opts) do + raise ArgumentError, + ~s|expected "inclusive" or "exclusive" for the `lower-bound` attribute of colocated css, got: #{inspect(val)}| + end + + defp validate_opt!(_opt, _other_opts), do: :ok + + defp do_scope(css, opts, meta) do + scope = hash("#{meta.module}_#{meta.line}: #{css}") + + root_tag_attribute = root_tag_attribute() + + upper_bound_selector = ~s|[phx-css-#{scope}]| + lower_bound_selector = ~s|[#{root_tag_attribute}]| + + lower_bound_selector = + case opts do + %{"lower-bound" => "inclusive"} -> lower_bound_selector <> " > *" + _ -> lower_bound_selector + end + + css = "@scope (#{upper_bound_selector}) to (#{lower_bound_selector}) { #{css} }" + + {scope, css} + end + + defp hash(string) do + # It is important that we do not pad + # the Base32 encoded value as we use it in + # an HTML attribute name and = (the padding character) + # is not valid. + string + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode32(case: :lower, padding: false) + end + + defp root_tag_attribute() do + case Application.get_env(:phoenix_live_view, :root_tag_attribute) do + configured_attribute when is_binary(configured_attribute) -> + configured_attribute + + configured_attribute -> + message = """ + a global :root_tag_attribute must be configured to use scoped css + + Expected global :root_tag_attribute to be a string, got: #{inspect(configured_attribute)} + + The global :root_tag_attribute is usually configured to `"phx-r"`, but it needs to be explicitly enabled in your configuration: + + config :phoenix_live_view, root_tag_attribute: "phx-r" + + You can also use a different value than `"phx-r"`. + """ + + raise ArgumentError, message + end + end +end + +defmodule Phoenix.LiveViewTest.Support.ColocatedGlobalCSS do + use Phoenix.LiveView.ColocatedCSS + + @impl true + def transform("style", _attrs, css, _meta) do + {:ok, css, []} + end +end