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