Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/live_style/attrs.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ defmodule LiveStyle.Attrs do
def to_list(%__MODULE__{class: class, style: style} = attrs) do
result = []
result = if style && style != "", do: [{:style, style} | result], else: result
result = if class && class != "", do: [{:class, attrs} | result], else: result
has_class = is_binary(class) and class != ""
has_prop_classes = is_list(attrs.prop_classes) and attrs.prop_classes != []
result = if has_class or has_prop_classes, do: [{:class, attrs} | result], else: result
result
end

Expand Down
27 changes: 24 additions & 3 deletions lib/live_style/class/include.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ defmodule LiveStyle.Class.Include do
- `manifest` - Optional manifest to look up local includes (for batch processing)
"""
@spec resolve(keyword(), atom(), LiveStyle.Manifest.t() | nil) :: keyword()
def resolve(declarations, caller_module, manifest \\ nil) when is_list(declarations) do
def resolve(declarations, caller_module, manifest \\ nil)

def resolve(declarations, caller_module, manifest) when is_list(declarations) do
do_resolve(declarations, caller_module, manifest, MapSet.new())
end

defp do_resolve(declarations, caller_module, manifest, seen) when is_list(declarations) do
{includes_list, regular} = Keyword.pop(declarations, :__include__, [])

# Normalize includes_list to always be a list
Expand All @@ -82,15 +88,30 @@ defmodule LiveStyle.Class.Include do
base =
includes_list
|> Enum.reduce([], fn include_ref, acc ->
ref_key = include_ref_key(include_ref, caller_module)

if MapSet.member?(seen, ref_key) do
raise CompileError,
description:
"LiveStyle: Include cycle detected: #{inspect(ref_key)}. " <>
"Classes cannot include each other in a cycle."
end

included = fetch_included_style(include_ref, caller_module, manifest)
# Recursively resolve includes in the included style
resolved = resolve(included, caller_module, manifest)
resolved = do_resolve(included, caller_module, manifest, MapSet.put(seen, ref_key))
Utils.merge_declarations(acc, resolved)
end)

Utils.merge_declarations(base, regular)
end

defp include_ref_key({module, rule_name}, _caller_module)
when is_atom(module) and is_atom(rule_name),
do: {module, rule_name}

defp include_ref_key(class_name, caller_module) when is_atom(class_name),
do: {caller_module, class_name}

defp fetch_included_style({module, rule_name}, caller_module, _manifest)
when is_atom(module) and is_atom(rule_name) do
# Record usage of the included external class for tree shaking
Expand Down
34 changes: 24 additions & 10 deletions lib/live_style/compiler/css/atomic_class.ex
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,34 @@ defmodule LiveStyle.Compiler.CSS.AtomicClass do
# Build declarations for all fallback values
# Order preserved: first value is the preferred one, subsequent values are fallbacks
# CSS applies in order, so last declaration wins if supported
decls =
Enum.map_join(values, ";", fn val ->
{ltr_prop, ltr_val} = RTL.generate_ltr(property, val)
"#{ltr_prop}:#{ltr_val}"
selector = Selector.build_atomic_class_selector(class_name, selector_suffix, at_rule)

# Generate LTR and RTL pairs for each fallback value
pairs =
Enum.map(values, fn val ->
ltr = RTL.generate_ltr(property, val)
rtl = RTL.generate_rtl(property, val)
{ltr, rtl}
end)

selector = Selector.build_atomic_class_selector(class_name, selector_suffix, at_rule)
ltr_css = "#{selector}{#{decls}}"
ltr_decls = Enum.map_join(pairs, ";", fn {{p, v}, _} -> "#{p}:#{v}" end)
ltr_css = wrap_in_at_rules(at_rule, "#{selector}{#{ltr_decls}}")

# Wrap in at-rule if present (handles nested at-rules)
ltr_css = wrap_in_at_rules(at_rule, ltr_css)
# Generate RTL CSS if any value differs in RTL
rtl_decls =
Enum.map_join(pairs, ";", fn {{lp, lv}, rtl} ->
{rp, rv} = rtl || {lp, lv}
"#{rp}:#{rv}"
end)

# RTL handling for arrays is more complex, skip for now
{ltr_css, nil}
rtl_css =
if rtl_decls == ltr_decls do
nil
else
wrap_in_at_rules(at_rule, "html[dir=\"rtl\"] #{selector}{#{rtl_decls}}")
end

{ltr_css, rtl_css}
end

@doc false
Expand Down
10 changes: 8 additions & 2 deletions lib/live_style/compiler/css/theme_classes.ex
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ defmodule LiveStyle.Compiler.CSS.ThemeClasses do
end
end

# Check if a list is a conditional value list
defp conditional_list?([{key, _} | _]) when is_atom(key) or is_binary(key), do: true
# Check if a list is a conditional value list (all elements must be {key, value} tuples)
defp conditional_list?([_ | _] = list) do
Enum.all?(list, fn
{k, _} when is_atom(k) or is_binary(k) -> true
_ -> false
end)
end

defp conditional_list?(_), do: false
end
24 changes: 19 additions & 5 deletions lib/live_style/compiler/css/vars.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,14 @@ defmodule LiveStyle.Compiler.CSS.Vars do
[{at_rules, ident, to_string(value)}]
end

# Check if a list is a conditional value list (keyword-like with :default or @-rule keys)
defp conditional_list?([{key, _} | _]) when is_atom(key) or is_binary(key), do: true
# Check if a list is a conditional value list (all elements must be {key, value} tuples)
defp conditional_list?([_ | _] = list) do
Enum.all?(list, fn
{k, _} when is_atom(k) or is_binary(k) -> true
_ -> false
end)
end

defp conditional_list?(_), do: false

# Handle default key with simple value
Expand Down Expand Up @@ -154,9 +160,17 @@ defmodule LiveStyle.Compiler.CSS.Vars do
default = Keyword.get(list, :default) || get_string_key(list, "default")

case default do
nil -> list |> List.first() |> elem(1) |> extract_initial_value()
val when is_binary(val) -> val
val -> extract_initial_value(val)
nil ->
case list do
[{_k, v} | _] -> extract_initial_value(v)
[] -> ""
end

val when is_binary(val) ->
val

val ->
extract_initial_value(val)
end
end

Expand Down
16 changes: 14 additions & 2 deletions lib/live_style/compiler/css/writer/file.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,22 @@ defmodule LiveStyle.Compiler.CSS.Writer.File do

defp write_file(path, content) do
dir = Path.dirname(path)
temp_path = Path.join(dir, ".#{Path.basename(path)}.tmp")

with :ok <- File.mkdir_p(dir),
:ok <- File.write(path, content) do
{:ok, :written}
:ok <- File.write(temp_path, content) do
case File.rename(temp_path, path) do
:ok ->
{:ok, :written}

{:error, reason} ->
File.rm(temp_path)
{:error, reason}
end
else
{:error, reason} ->
File.rm(temp_path)
{:error, reason}
end
end
end
62 changes: 48 additions & 14 deletions lib/live_style/compiler/module_data.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,19 @@ defmodule LiveStyle.Compiler.ModuleData do
def write(module, data) when is_atom(module) and is_map(data) do
path = module_path(module)
dir = Path.dirname(path)

File.mkdir_p!(dir)
File.write!(path, :erlang.term_to_binary(data))

temp_path = path <> ".tmp"

try do
File.write!(temp_path, :erlang.term_to_binary(data))
File.rename!(temp_path, path)
rescue
error ->
File.rm(temp_path)
reraise error, __STACKTRACE__
end

:ok
end

Expand All @@ -41,12 +51,18 @@ defmodule LiveStyle.Compiler.ModuleData do
path = module_path(module)

if File.exists?(path) do
path
|> File.read!()
|> :erlang.binary_to_term()
case File.read(path) do
{:ok, binary} ->
:erlang.binary_to_term(binary)

{:error, _} ->
nil
end
else
nil
end
catch
:error, :badarg -> nil
end

@doc """
Expand All @@ -62,11 +78,9 @@ defmodule LiveStyle.Compiler.ModuleData do
{:ok, files} ->
files
|> Enum.filter(&String.ends_with?(&1, ".etf"))
|> Enum.map(fn file ->
|> Enum.flat_map(fn file ->
path = Path.join(modules_dir(), file)
data = path |> File.read!() |> :erlang.binary_to_term()
module = data[:module]
{module, data}
read_module_file(path)
end)

{:error, _} ->
Expand All @@ -86,12 +100,16 @@ defmodule LiveStyle.Compiler.ModuleData do
for file <- files, String.ends_with?(file, ".etf") do
path = Path.join(modules_dir(), file)

with {:ok, binary} <- File.read(path),
data <- :erlang.binary_to_term(binary),
module when is_atom(module) <- data[:module] do
unless MapSet.member?(active_modules, module) do
File.rm(path)
try do
with {:ok, binary} <- File.read(path),
data <- :erlang.binary_to_term(binary),
module when is_atom(module) <- data[:module] do
unless MapSet.member?(active_modules, module) do
File.rm(path)
end
end
catch
:error, :badarg -> File.rm(path)
end
end

Expand All @@ -113,6 +131,22 @@ defmodule LiveStyle.Compiler.ModuleData do
Path.join([build_path, "live_style", to_string(app), "modules"])
end

defp read_module_file(path) do
case File.read(path) do
{:ok, binary} ->
try do
data = :erlang.binary_to_term(binary)
module = data[:module]
if is_atom(module), do: [{module, data}], else: []
catch
:error, :badarg -> []
end

{:error, _} ->
[]
end
end

defp module_path(module) do
# Use a hash of the module name to avoid filesystem issues with long names
hash = :crypto.hash(:md5, inspect(module)) |> Base.encode16(case: :lower)
Expand Down
15 changes: 10 additions & 5 deletions lib/live_style/compiler/watch.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,14 @@ defmodule LiveStyle.Compiler.Watch do
new_hash = compute_modules_hash(modules_dir)

if new_hash != last_hash do
# Content changed - merge and regenerate
LiveStyle.Storage.merge_module_data()
_ = run_once_fun.(output, input)
try do
LiveStyle.Storage.merge_module_data()
_ = run_once_fun.(output, input)
rescue
e ->
require Logger
Logger.error("LiveStyle watch: regeneration failed: #{Exception.message(e)}")
end
end

new_hash
Expand All @@ -96,7 +101,7 @@ defmodule LiveStyle.Compiler.Watch do
|> Enum.filter(&String.ends_with?(&1, ".etf"))
|> Enum.sort()
|> Enum.map(&file_content_hash(modules_dir, &1))
|> :erlang.md5()
|> then(&:crypto.hash(:md5, &1))

{:error, _} ->
<<>>
Expand All @@ -105,7 +110,7 @@ defmodule LiveStyle.Compiler.Watch do

defp file_content_hash(dir, file) do
case File.read(Path.join(dir, file)) do
{:ok, content} -> :erlang.md5(content)
{:ok, content} -> :crypto.hash(:md5, content)
{:error, _} -> <<>>
end
end
Expand Down
10 changes: 8 additions & 2 deletions lib/live_style/css_value/normalize.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,16 @@ defmodule LiveStyle.CSSValue.Normalize do
# Convert milliseconds to seconds when >= 10ms
defp normalize_timings(value) do
Regex.replace(@ms_timing_regex, value, fn _, num_str ->
num = String.to_float(num_str <> ".0") |> Float.round(4)
num =
if String.contains?(num_str, ".") do
String.to_float(num_str)
else
String.to_float(num_str <> ".0")
end
|> Float.round(4)

if num >= 10 do
seconds = num / 1000
seconds = Float.round(num / 1000, 4)
"#{seconds}s"
else
"#{num_str}ms"
Expand Down
38 changes: 13 additions & 25 deletions lib/live_style/manifest.ex
Original file line number Diff line number Diff line change
Expand Up @@ -183,33 +183,21 @@ defmodule LiveStyle.Manifest do
sorted_list_get(list, key)
end

# Insert or update in a sorted list, maintaining sort order
defp sorted_list_put(list, key, entry) do
case sorted_list_find_index(list, key) do
{:found, index} ->
List.replace_at(list, index, {key, entry})

{:insert_at, index} ->
List.insert_at(list, index, {key, entry})
end
end
# Single-pass insert or update in a sorted list, maintaining sort order
defp sorted_list_put([], key, entry), do: [{key, entry}]

# Get value from sorted list by key (linear search, but could use binary search)
defp sorted_list_get(list, key) do
case List.keyfind(list, key, 0) do
{^key, entry} -> entry
nil -> nil
end
end
defp sorted_list_put([{k, _v} = head | rest], key, entry) when key < k,
do: [{key, entry}, head | rest]

# Find index where key exists or should be inserted
defp sorted_list_find_index(list, key) do
find_index(list, key, 0)
end
defp sorted_list_put([{k, _} | rest], key, entry) when key == k,
do: [{key, entry} | rest]

defp find_index([], _key, index), do: {:insert_at, index}
defp sorted_list_put([head | rest], key, entry),
do: [head | sorted_list_put(rest, key, entry)]

defp find_index([{k, _} | _rest], key, index) when key < k, do: {:insert_at, index}
defp find_index([{k, _} | _rest], key, index) when key == k, do: {:found, index}
defp find_index([_ | rest], key, index), do: find_index(rest, key, index + 1)
# Single-pass get from sorted list with early stop
defp sorted_list_get([], _key), do: nil
defp sorted_list_get([{k, _} | _rest], key) when key < k, do: nil
defp sorted_list_get([{k, entry} | _rest], key) when key == k, do: entry
defp sorted_list_get([_ | rest], key), do: sorted_list_get(rest, key)
end
Loading