defmodule Vize.CSS do
@moduledoc """
CSS parsing, printing, and AST traversal helpers.
The AST is produced by LightningCSS and represented as Elixir maps, lists,
strings, numbers, booleans, and `nil`. Use `parse_ast/2` to parse CSS,
transform the returned AST with `prewalk/2` or `postwalk/2`, and then use
`print_ast/2` to serialize it back to CSS.
"""
@type css_result :: %{
optional(:exports) => %{optional(String.t()) => String.t()} | nil,
code: String.t(),
css_vars: [String.t()],
errors: [String.t()],
warnings: [String.t()]
}
@type ast :: map() | list() | String.t() | number() | boolean() | nil
@type ast_result :: %{
ast: map() | nil,
errors: [String.t()],
warnings: [String.t()]
}
@type url_ref :: Vize.CSS.URL.t()
@doc """
Compile CSS using LightningCSS.
Parses, autoprefixes, and optionally minifies CSS. Also handles Vue scoped CSS
transformation, `v-bind()` extraction, and CSS Modules.
## Options
* `:minify` — minify the output (default: `false`)
* `:scoped` — apply Vue scoped CSS transformation (default: `false`)
* `:scope_id` — scope ID for scoped CSS (e.g. `"data-v-abc123"`)
* `:filename` — filename for error reporting
* `:css_modules` — enable CSS Modules scoping (default: `false`)
* `:targets` — browser targets for autoprefixing, map with optional
`:chrome`, `:firefox`, `:safari` keys as major version integers
## Examples
iex> {:ok, result} = Vize.CSS.compile(".foo { color: red }")
iex> result.code =~ "color"
true
iex> result.errors
[]
"""
@spec compile(String.t(), keyword()) :: {:ok, css_result()}
def compile(source, opts \\ []) do
minify = Keyword.get(opts, :minify, false)
scoped = Keyword.get(opts, :scoped, false)
scope_id = Keyword.get(opts, :scope_id, "")
filename = Keyword.get(opts, :filename, "")
css_modules = Keyword.get(opts, :css_modules, false)
targets = Keyword.get(opts, :targets, %{})
chrome = Map.get(targets, :chrome, -1)
firefox = Map.get(targets, :firefox, -1)
safari = Map.get(targets, :safari, -1)
Vize.Native.compile_css_nif(
source,
minify,
scoped,
scope_id,
filename,
chrome,
firefox,
safari,
css_modules
)
end
@doc "Like `compile/2` but raises on errors."
@spec compile!(String.t(), keyword()) :: css_result()
def compile!(source, opts \\ []) do
case compile(source, opts) do
{:ok, result} ->
if result.errors != [] do
raise "Vize CSS compile error: #{inspect(result.errors)}"
end
result
end
end
@doc """
Bundle a CSS file and all its `@import` dependencies into a single stylesheet.
Reads the entry file and all imported files from disk, resolving `@import`
rules recursively. The result is a single merged stylesheet with all imports
inlined, wrapped in the appropriate `@media`, `@supports`, and `@layer` rules.
## Options
* `:minify` — minify the output (default: `false`)
* `:css_modules` — enable CSS Modules scoping (default: `false`)
* `:targets` — browser targets for autoprefixing
"""
@spec bundle(String.t(), keyword()) :: {:ok, css_result()}
def bundle(entry_path, opts \\ []) do
minify = Keyword.get(opts, :minify, false)
css_modules = Keyword.get(opts, :css_modules, false)
targets = Keyword.get(opts, :targets, %{})
chrome = Map.get(targets, :chrome, -1)
firefox = Map.get(targets, :firefox, -1)
safari = Map.get(targets, :safari, -1)
Vize.Native.bundle_css_nif(
Path.expand(entry_path),
minify,
chrome,
firefox,
safari,
css_modules
)
end
@doc "Like `bundle/2` but raises on errors."
@spec bundle!(String.t(), keyword()) :: css_result()
def bundle!(entry_path, opts \\ []) do
case bundle(entry_path, opts) do
{:ok, result} ->
if result.errors != [] do
raise "Vize CSS bundle error: #{inspect(result.errors)}"
end
result
end
end
@doc """
Select compact parser events from CSS source by name.
Available selectors:
* `:urls` — `url()` references with source byte ranges and source locations
## Options
* `:filename` — filename for parser locations and error reporting
* `:css_modules` — enable CSS Modules parsing (default: `false`)
* `:custom_media` — enable custom media parsing (default: `false`)
"""
@spec select(String.t(), atom(), keyword()) :: {:ok, [map()]} | {:error, Vize.Error.t()}
def select(source, selector, opts \\ []) when is_atom(selector) do
filename = Keyword.get(opts, :filename, "")
css_modules = Keyword.get(opts, :css_modules, false)
custom_media = Keyword.get(opts, :custom_media, false)
case Vize.Native.select_css_nif(
source,
filename,
custom_media,
css_modules,
selector_spec(selector)
) do
{:ok, events} -> {:ok, events}
{:error, errors} -> {:error, error("Vize CSS selection error", errors)}
end
end
@doc """
Collect parser-backed `url()` references from CSS source.
Returns byte offsets for the URL value inside the original source, suitable for
source patching without round-tripping through the serialized CSS AST.
"""
@spec collect_urls(String.t(), keyword()) :: {:ok, [url_ref()]} | {:error, Vize.Error.t()}
def collect_urls(source, opts \\ []) do
case select(source, :urls, opts) do
{:ok, urls} -> {:ok, Enum.map(urls, &Vize.CSS.URL.new/1)}
{:error, _} = error -> error
end
end
defp selector_spec(:urls) do
[
{{:css_url, :"$1", :"$2", :"$3", :"$4", :"$5", :"$6", :"$7"}, [],
[
%{
url: :"$1",
start: :"$2",
end: :"$3",
start_line: :"$4",
start_column: :"$5",
end_line: :"$6",
end_column: :"$7"
}
]}
]
end
defp selector_spec(selector) do
raise ArgumentError, "unknown Vize CSS selector #{inspect(selector)}"
end
@doc "Like `collect_urls/2` but raises `Vize.Error` on errors."
@spec collect_urls!(String.t(), keyword()) :: [url_ref()]
def collect_urls!(source, opts \\ []) do
case collect_urls(source, opts) do
{:ok, urls} -> urls
{:error, error} -> raise error
end
end
@doc """
Rewrite parser-backed `url()` references in CSS source.
The callback receives each URL string and returns either `:keep` or
`{:rewrite, new_url}`. Rewrites are applied to the original source using byte
ranges reported by the native parser.
"""
@spec rewrite_urls(String.t(), keyword(), (String.t() -> :keep | {:rewrite, iodata()})) ::
{:ok, String.t()} | {:error, Vize.Error.t()}
def rewrite_urls(source, fun) when is_function(fun, 1), do: rewrite_urls(source, [], fun)
def rewrite_urls(source, opts, fun) when is_function(fun, 1) do
with {:ok, urls} <- collect_urls(source, opts) do
patches =
Enum.reduce(urls, [], fn %Vize.CSS.URL{url: url, range: range}, acc ->
case fun.(url) do
{:rewrite, replacement} ->
[%{start: range.start, end: range.end, change: replacement} | acc]
:keep ->
acc
end
end)
{:ok, patch_string(source, patches)}
end
end
@doc "Like `rewrite_urls/3` but raises `Vize.Error` on errors."
@spec rewrite_urls!(String.t(), keyword(), (String.t() -> :keep | {:rewrite, iodata()})) ::
String.t()
def rewrite_urls!(source, fun) when is_function(fun, 1), do: rewrite_urls!(source, [], fun)
def rewrite_urls!(source, opts, fun) when is_function(fun, 1) do
case rewrite_urls(source, opts, fun) do
{:ok, source} -> source
{:error, error} -> raise error
end
end
@doc """
Parse CSS into a LightningCSS-backed AST represented as Elixir maps and lists.
## Options
* `:filename` — filename for parser locations and error reporting
* `:css_modules` — enable CSS Modules parsing (default: `false`)
* `:custom_media` — enable custom media parsing (default: `false`)
## Examples
iex> {:ok, result} = Vize.CSS.parse_ast(".foo { background: url('./logo.svg') }")
iex> is_map(result.ast)
true
iex> result.errors
[]
"""
@spec parse_ast(String.t(), keyword()) :: {:ok, ast_result()}
def parse_ast(source, opts \\ []) do
filename = Keyword.get(opts, :filename, "")
css_modules = Keyword.get(opts, :css_modules, false)
custom_media = Keyword.get(opts, :custom_media, false)
Vize.Native.parse_css_ast_nif(source, filename, custom_media, css_modules)
end
@doc "Like `parse_ast/2` but raises on errors."
@spec parse_ast!(String.t(), keyword()) :: ast_result()
def parse_ast!(source, opts \\ []) do
case parse_ast(source, opts) do
{:ok, result} ->
if result.errors != [] do
raise "Vize CSS parse error: #{inspect(result.errors)}"
end
result
end
end
@doc """
Print CSS from an AST returned by `parse_ast/2`.
## Options
* `:minify` — minify the output (default: `false`)
* `:targets` — browser targets for autoprefixing, map with optional
`:chrome`, `:firefox`, `:safari` keys as major version integers
## Examples
iex> {:ok, parsed} = Vize.CSS.parse_ast(".foo { color: red }")
iex> {:ok, printed} = Vize.CSS.print_ast(parsed.ast)
iex> printed.code =~ "color"
true
"""
@spec print_ast(map(), keyword()) :: {:ok, css_result()}
def print_ast(ast, opts \\ []) do
minify = Keyword.get(opts, :minify, false)
targets = Keyword.get(opts, :targets, %{})
chrome = Map.get(targets, :chrome, -1)
firefox = Map.get(targets, :firefox, -1)
safari = Map.get(targets, :safari, -1)
Vize.Native.print_css_ast_nif(ast, minify, chrome, firefox, safari)
end
@doc "Like `print_ast/2` but raises on errors."
@spec print_ast!(map(), keyword()) :: css_result()
def print_ast!(ast, opts \\ []) do
case print_ast(ast, opts) do
{:ok, result} ->
if result.errors != [] do
raise "Vize CSS print error: #{inspect(result.errors)}"
end
result
end
end
@doc """
Traverse the AST in pre-order and call `fun` for every map node.
Returns `:ok`. Use `prewalk/2` or `postwalk/2` when you need to transform
the AST.
"""
@spec walk(ast(), (map() -> any())) :: :ok
def walk(value, fun) when is_function(fun, 1) do
prewalk(value, fn
node when is_map(node) ->
fun.(node)
node
other ->
other
end)
:ok
end
@doc """
Depth-first pre-order traversal, like `Macro.prewalk/2`.
The callback receives every map node before its children and must return the
node to continue traversing.
"""
@spec prewalk(ast(), (map() -> map())) :: ast()
def prewalk(value, fun) when is_function(fun, 1) do
do_prewalk(value, fn node, acc -> {fun.(node), acc} end, nil) |> elem(0)
end
@doc """
Depth-first pre-order traversal with accumulator, like `Macro.prewalk/3`.
"""
@spec prewalk(ast(), acc, (map(), acc -> {map(), acc})) :: {ast(), acc} when acc: term()
def prewalk(value, acc, fun) when is_function(fun, 2) do
do_prewalk(value, fun, acc)
end
@doc """
Depth-first post-order traversal, like `Macro.postwalk/2`.
The callback receives every map node after its children and must return the
transformed node.
"""
@spec postwalk(ast(), (map() -> map())) :: ast()
def postwalk(value, fun) when is_function(fun, 1) do
do_postwalk(value, fn node, acc -> {fun.(node), acc} end, nil) |> elem(0)
end
@doc """
Depth-first post-order traversal with accumulator, like `Macro.postwalk/3`.
"""
@spec postwalk(ast(), acc, (map(), acc -> {map(), acc})) :: {ast(), acc} when acc: term()
def postwalk(value, acc, fun) when is_function(fun, 2) do
do_postwalk(value, fun, acc)
end
@doc """
Collect values from map nodes that match `fun`.
`fun` should return `{:keep, value}` to include a value, or `:skip` to ignore
the node.
"""
@spec collect(ast(), (map() -> {:keep, term()} | :skip)) :: [term()]
def collect(value, fun) when is_function(fun, 1) do
{_value, collected} =
postwalk(value, [], fn node, acc ->
case fun.(node) do
{:keep, value} -> {node, [value | acc]}
:skip -> {node, acc}
end
end)
Enum.reverse(collected)
end
defp error(message, errors), do: Vize.Error.new(message, errors)
defp patch_string(source, patches) do
{chunks, offset} =
patches
|> Enum.uniq_by(fn %{start: start, end: end_offset} -> {start, end_offset} end)
|> Enum.sort_by(fn %{start: start} -> start end)
|> Enum.reduce({[], 0}, fn %{start: start, end: end_offset, change: replacement},
{chunks, offset} ->
chunk = binary_part(source, offset, start - offset)
{[replacement, chunk | chunks], end_offset}
end)
tail = binary_part(source, offset, byte_size(source) - offset)
IO.iodata_to_binary(Enum.reverse([tail | chunks]))
end
defp do_prewalk(value, fun, acc) when is_map(value) do
{value, acc} = fun.(value, acc)
Enum.reduce(value, {value, acc}, fn {key, child}, {node, acc} ->
{child, acc} = do_prewalk(child, fun, acc)
{Map.put(node, key, child), acc}
end)
end
defp do_prewalk(value, fun, acc) when is_list(value) do
Enum.map_reduce(value, acc, &do_prewalk(&1, fun, &2))
end
defp do_prewalk(value, _fun, acc), do: {value, acc}
defp do_postwalk(value, fun, acc) when is_map(value) do
{value, acc} =
Enum.reduce(value, {value, acc}, fn {key, child}, {node, acc} ->
{child, acc} = do_postwalk(child, fun, acc)
{Map.put(node, key, child), acc}
end)
fun.(value, acc)
end
defp do_postwalk(value, fun, acc) when is_list(value) do
Enum.map_reduce(value, acc, &do_postwalk(&1, fun, &2))
end
defp do_postwalk(value, _fun, acc), do: {value, acc}
end