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