Skip to content

Commit 3fa0c5a

Browse files
committed
Fix safelist module: use try/rescue for NIF, fix recompile pattern
- Remove Code.ensure_compiled calls that fail at compile time in umbrella apps (TailwindCompiler module not available yet) - Use try/rescue fallback: validate through NIF if available, fall back to unvalidated tokens if NIF isn't loaded - Note: glob paths must be relative to the app directory in umbrella projects, not the umbrella root
1 parent 6efdb43 commit 3fa0c5a

1 file changed

Lines changed: 80 additions & 102 deletions

File tree

Lines changed: 80 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
defmodule Mix.Tasks.Beacon.Gen.Safelist do
2-
@shortdoc "Generates a CSS safelist module from host app source files"
2+
@shortdoc "Generates a self-recompiling CSS safelist module"
33

44
@moduledoc """
5-
Extracts Tailwind CSS class names from your host app's source files and
6-
generates a compiled Elixir module containing only validated class names.
5+
Generates a module that automatically extracts and validates Tailwind CSS
6+
class names from your host app's source files at compile time.
77
8-
This ensures that CSS classes used in your Phoenix templates (outside of
9-
Beacon CMS content) are included in Beacon's CSS compilation.
8+
The generated module uses `__mix_recompile__?/0` to detect when source
9+
files change and automatically re-extracts classes on the next `mix compile`.
1010
1111
## Usage
1212
@@ -22,28 +22,13 @@ defmodule Mix.Tasks.Beacon.Gen.Safelist do
2222
2323
mix beacon.gen.safelist \\
2424
--module DockYardWeb.BeaconSafelist \\
25-
--paths "lib/*_web/components/**/*.ex,lib/*_web/components/**/*.heex,lib/*_web/live/**/*.ex,lib/*_web/live/**/*.heex,lib/*_web/layouts/**/*.heex,lib/*_web/controllers/**/*.heex"
25+
--paths "lib/*_web/components/**/*.ex,lib/*_web/components/**/*.heex,lib/*_web/live/**/*.ex,lib/*_web/live/**/*.heex"
2626
27-
The generated module:
28-
29-
defmodule DockYardWeb.BeaconSafelist do
30-
@moduledoc false
31-
# Auto-generated by mix beacon.gen.safelist. Do not edit.
32-
33-
@candidates ~w[
34-
antialiased
35-
bg-white
36-
hover:bg-blue-50
37-
md:leading-8
38-
]
39-
40-
def list, do: @candidates
41-
end
42-
43-
Add the module to your Beacon site config:
27+
Then add to your Beacon site config:
4428
4529
css_safelist_module: DockYardWeb.BeaconSafelist
4630
31+
The module will automatically recompile when source files change.
4732
"""
4833

4934
use Mix.Task
@@ -57,7 +42,6 @@ defmodule Mix.Tasks.Beacon.Gen.Safelist do
5742

5843
module_name = opts[:module] || Mix.raise("--module is required")
5944
paths_str = opts[:paths] || Mix.raise("--paths is required")
60-
paths = String.split(paths_str, ",", trim: true) |> Enum.map(&String.trim/1)
6145

6246
output_path =
6347
opts[:output] ||
@@ -66,101 +50,95 @@ defmodule Mix.Tasks.Beacon.Gen.Safelist do
6650
|> Macro.underscore()
6751
|> then(&"lib/#{&1}.ex")
6852

69-
Mix.shell().info("Scanning source files for Tailwind classes...")
70-
71-
# Expand globs and read files
72-
files =
73-
paths
74-
|> Enum.flat_map(&Path.wildcard/1)
75-
|> Enum.uniq()
76-
|> Enum.filter(&File.regular?/1)
77-
78-
Mix.shell().info(" Found #{length(files)} source files")
79-
80-
# Extract raw tokens from class attributes
81-
tokens =
82-
files
83-
|> Enum.flat_map(&extract_tokens/1)
84-
|> Enum.uniq()
85-
86-
Mix.shell().info(" Extracted #{length(tokens)} raw tokens")
87-
88-
# Validate through TailwindCompiler NIF
89-
Mix.Task.run("app.config")
90-
91-
valid =
92-
case TailwindCompiler.validate(tokens) do
93-
candidates when is_list(candidates) -> Enum.sort(candidates)
94-
_ -> Mix.raise("TailwindCompiler.validate/1 failed")
95-
end
96-
97-
Mix.shell().info(" Validated #{length(valid)} Tailwind class names")
98-
99-
# Generate module
100-
content = generate_module(module_name, valid, paths)
53+
content = generate_module(module_name, paths_str)
10154

102-
# Write file
10355
output_dir = Path.dirname(output_path)
10456
File.mkdir_p!(output_dir)
10557
File.write!(output_path, content)
10658

107-
Mix.shell().info(" Generated #{output_path}")
59+
Mix.shell().info("Generated #{output_path}")
60+
Mix.shell().info("")
61+
Mix.shell().info("The module will automatically extract and validate Tailwind")
62+
Mix.shell().info("classes from your source files at compile time.")
10863
Mix.shell().info("")
10964
Mix.shell().info("Add to your Beacon site config:")
11065
Mix.shell().info("")
11166
Mix.shell().info(" css_safelist_module: #{module_name}")
11267
Mix.shell().info("")
11368
end
11469

115-
defp extract_tokens(file_path) do
116-
file_path
117-
|> File.read!()
118-
|> extract_from_content()
119-
end
120-
121-
defp extract_from_content(content) do
122-
# Find class="..." and class={...} attributes
123-
~r/class\s*[=:]\s*["']([^"']*)["']/i
124-
|> Regex.scan(content, capture: :all_but_first)
125-
|> List.flatten()
126-
|> Enum.flat_map(&String.split/1)
127-
|> Enum.map(&String.trim/1)
128-
|> Enum.filter(&valid_token?/1)
129-
end
130-
131-
defp valid_token?(token) do
132-
byte_size(token) > 1 and
133-
String.match?(token, ~r/^[!@a-z\-\[]/i) and
134-
String.match?(token, ~r/[a-z]/i) and
135-
not String.starts_with?(token, "//") and
136-
not String.starts_with?(token, "http") and
137-
not String.starts_with?(token, "{{") and
138-
not String.contains?(token, "==")
139-
end
140-
141-
defp generate_module(module_name, candidates, paths) do
142-
timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
143-
paths_str = Enum.join(paths, ", ")
144-
145-
candidates_str =
146-
candidates
147-
|> Enum.map(&" #{&1}")
148-
|> Enum.join("\n")
149-
150-
"""
70+
defp generate_module(module_name, paths_str) do
71+
~s'''
15172
defmodule #{module_name} do
15273
@moduledoc false
153-
# Auto-generated by mix beacon.gen.safelist. Do not edit.
154-
# Generated at: #{timestamp}
155-
# Source paths: #{paths_str}
156-
# Candidates found: #{length(candidates)}
74+
# Generated by mix beacon.gen.safelist.
75+
# This module auto-recompiles when source files change.
76+
77+
@paths ~w[#{paths_str |> String.split(",") |> Enum.map_join(" ", &String.trim/1)}]
78+
79+
# Expand globs and hash file contents at compile time
80+
@source_files @paths
81+
|> Enum.flat_map(&Path.wildcard/1)
82+
|> Enum.uniq()
83+
|> Enum.filter(&File.regular?/1)
84+
|> Enum.sort()
85+
86+
@source_hash @source_files
87+
|> Enum.map(&File.read!/1)
88+
|> :erlang.md5()
89+
90+
# Extract and validate candidates at compile time
91+
@candidates (
92+
tokens =
93+
@source_files
94+
|> Enum.flat_map(fn file ->
95+
file
96+
|> File.read!()
97+
|> then(fn content ->
98+
~r/class\\s*[=:]\\s*["']([^"']*)["']/i
99+
|> Regex.scan(content, capture: :all_but_first)
100+
|> List.flatten()
101+
|> Enum.flat_map(&String.split/1)
102+
|> Enum.map(&String.trim/1)
103+
|> Enum.filter(fn token ->
104+
byte_size(token) > 1 and
105+
String.match?(token, ~r/^[!@a-z\\-\\[]/i) and
106+
String.match?(token, ~r/[a-z]/i) and
107+
not String.starts_with?(token, "//") and
108+
not String.starts_with?(token, "http") and
109+
not String.starts_with?(token, "{{") and
110+
not String.contains?(token, "==")
111+
end)
112+
end)
113+
end)
114+
|> Enum.uniq()
115+
116+
try do
117+
TailwindCompiler.validate(tokens) |> Enum.sort()
118+
rescue
119+
_ -> tokens |> Enum.sort()
120+
end
121+
)
157122
158-
@candidates ~w[
159-
#{candidates_str}
160-
]
123+
def __mix_recompile__? do
124+
source_files =
125+
@paths
126+
|> Enum.flat_map(&Path.wildcard/1)
127+
|> Enum.uniq()
128+
|> Enum.filter(&File.regular?/1)
129+
|> Enum.sort()
130+
131+
hash =
132+
source_files
133+
|> Enum.map(&File.read!/1)
134+
|> :erlang.md5()
135+
136+
hash != @source_hash
137+
end
161138
139+
@doc "Returns the list of validated Tailwind CSS candidates from host app source files."
162140
def list, do: @candidates
163141
end
164-
"""
142+
'''
165143
end
166144
end

0 commit comments

Comments
 (0)