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 +
World
<---- root tag + +Bar
<---- not a root tag +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""" + ++ {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