11defmodule 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
166144end
0 commit comments