11defmodule Beacon.Template.HEExConverter do
22 @ moduledoc """
3- Best-effort conversion of HEEx templates to the new Beacon template syntax.
3+ Converts HEEx templates to Beacon template syntax.
44
5- Handles common patterns :
5+ Handles:
66 - `<%= @var %>` → `{{ var }}`
7- - `<%= @var["key"] %> ` → `{{ var.key }} `
8- - `<%= if ... do %>...<% end %>` → `:if="..."`
9- - `<%= for ... <- ... do %>...<% end %>` → `:for="..."`
7+ - `@var["key"]["key2"] ` → `var.key.key2 `
8+ - `<%= if ... do %>...<% end %>` wrapping a single element → `:if` on that element
9+ - `<%= for x <- list do %>...<% end %>` wrapping a single element → `:for` on that element
1010 - `<.link navigate={path}>text</.link>` → `<a href="path">text</a>`
11- - `my_component("name", props)` → `<name props />`
11+ - Known function calls → enriched field references
12+ - `<%= ... %>` expressions → `{{ ... }}`
13+ - `phx-submit` → `@submit`, `phx-click` → `@click`
1214
13- Flags unconvertible patterns (Elixir function calls, complex expressions)
14- with `<!-- MANUAL: ... -->` comments.
15+ Returns `{converted_template, warnings}`.
1516 """
1617
17- @ doc """
18- Convert a HEEx template string to Beacon template syntax.
18+ # Known function call → enriched field replacements
19+ @ function_replacements [
20+ # PostView
21+ { ~r/ DockYardWeb\. PostView\. post_path\( ([^)]+)\) / , "\\ 1.post_path" } ,
22+ { ~r/ DockYardWeb\. PostView\. post_url\( ([^)]+)\) / , "\\ 1.post_url" } ,
23+ { ~r/ DockYardWeb\. PostView\. illustration\( ([^,]+),\s *:large\) / , "\\ 1.illustration_large" } ,
24+ { ~r/ DockYardWeb\. PostView\. illustration\( ([^,]+),\s *:small\) / , "\\ 1.illustration_small" } ,
25+ { ~r/ DockYardWeb\. PostView\. illustration_alt\( ([^)]+)\) / , "\\ 1.illustration_alt_text" } ,
26+ { ~r/ DockYardWeb\. PostView\. author\( ([^)]+)\) / , "\\ 1.author_name" } ,
27+ { ~r/ DockYardWeb\. PostView\. author_avatar_url\( ([^)]+)\) / , "\\ 1.author_avatar_url" } ,
28+ { ~r/ DockYardWeb\. PostView\. twitter_share_url\( ([^)]+)\) / , "\\ 1.twitter_share_url" } ,
29+ { ~r/ DockYardWeb\. PostView\. bluesky_share_url\( ([^)]+)\) / , "\\ 1.bluesky_share_url" } ,
30+ { ~r/ DockYardWeb\. PostView\. linkedin_share_url\( ([^)]+)\) / , "\\ 1.linkedin_share_url" } ,
31+ { ~r/ DockYardWeb\. PostView\. article_link_attrs\( ([^)]+)\) / , "\\ 1.article_link_href" } ,
32+ # EmployeeView
33+ { ~r/ DockYardWeb\. EmployeeView\. random_avatar_uri\( ([^)]+)\) / , "\\ 1.avatar_uri" } ,
34+ { ~r/ DockYardWeb\. EmployeeView\. display_name\( ([^)]+)\) / , "\\ 1.display_name" } ,
35+ # Employee
36+ { ~r/ DockYard\. Employees\. Employee\. full_name\( ([^)]+)\) / , "\\ 1.author_name" } ,
37+ # Beacon helpers
38+ { ~r/ Beacon\. Template\. Helpers\. format_datetime\( ([^,]+),\s *"([^"]+)"(?:,\s *"([^"]*)")?\) / , "\\ 1 | format_date: \" \\ 2\" " } ,
39+ { ~r/ Beacon\. Template\. Helpers\. to_iso_date\( ([^)]+)\) / , "\\ 1 | format_date: \" %Y-%m-%d\" " } ,
40+ # Stdlib
41+ { ~r/ Enum\. join\( ([^,]+),\s *"([^"]*)"\) / , "\\ 1 | join: \" \\ 2\" " } ,
42+ { ~r/ Jason\. encode!\( ([^)]+)\) / , "\\ 1 | json" }
43+ ]
1944
20- Returns `{converted_template, warnings}` where warnings is a list of
21- strings describing patterns that could not be automatically converted.
22- """
2345 @ spec convert ( binary ( ) ) :: { binary ( ) , [ binary ( ) ] }
2446 def convert ( template ) when is_binary ( template ) do
25- warnings = [ ]
26-
27- { result , warnings } = { template , warnings }
47+ { result , warnings } = { template , [ ] }
48+ |> replace_known_functions ( )
49+ |> convert_eex_expressions ( )
2850 |> convert_assigns ( )
2951 |> convert_bracket_access ( )
3052 |> convert_phoenix_links ( )
53+ |> convert_phx_events ( )
3154 |> convert_simple_conditionals ( )
3255 |> convert_simple_loops ( )
33- |> flag_function_calls ( )
34- |> flag_my_component_calls ( )
56+ |> convert_eex_output_tags ( )
57+ |> cleanup_eex_remnants ( )
58+ |> flag_remaining_issues ( )
59+
60+ { result , Enum . uniq ( Enum . reverse ( warnings ) ) }
61+ end
3562
36- { result , Enum . reverse ( warnings ) }
63+ # Replace known function calls with enriched field references
64+ defp replace_known_functions ( { template , warnings } ) do
65+ result = Enum . reduce ( @ function_replacements , template , fn { pattern , replacement } , acc ->
66+ Regex . replace ( pattern , acc , replacement )
67+ end )
68+
69+ { result , warnings }
3770 end
3871
39- # @var → var (strip @ prefix for assigns)
40- defp convert_assigns ( { template , warnings } ) when is_binary ( template ) do
41- # <%= @var %> → {{ var }}
72+ # Convert simple <%= expr %> to {{ expr }}
73+ defp convert_eex_expressions ( { template , warnings } ) do
74+ # {expr} (HEEx expression tags) → {{ expr }}
75+ # But only for simple expressions, not control flow
4276 result = Regex . replace (
43- ~r/ <%= \s *@ ([a-zA-Z_][a-zA-Z0-9_]*) \s *%> / ,
77+ ~r/ \{ ([a-zA-Z_][a-zA-Z0-9_.| :"%-]+) \} / ,
4478 template ,
45- "{{ \\ 1 }}"
79+ fn full , expr ->
80+ expr = String . trim ( expr )
81+ # Don't convert if it's an HTML attribute value, class expression, etc.
82+ if String . contains? ( expr , ":" ) and not String . contains? ( expr , "|" ) do
83+ full # Leave as-is (likely a keyword list or map)
84+ else
85+ "{{ #{ expr } }}"
86+ end
87+ end
4688 )
4789
48- # @var["key"] inside expressions → var.key
49- result = Regex . replace (
50- ~r/ @([a-zA-Z_][a-zA-Z0-9_]*)\[ "([a-zA-Z_][a-zA-Z0-9_]*)"\] / ,
51- result ,
52- "\\ 1.\\ 2"
53- )
90+ { result , warnings }
91+ end
5492
93+ # @var → var (strip @ prefix)
94+ defp convert_assigns ( { template , warnings } ) do
95+ # @var in expression context → var
96+ result = Regex . replace ( ~r/ @([a-zA-Z_][a-zA-Z0-9_]*)/ , template , "\\ 1" )
5597 { result , warnings }
5698 end
5799
58- # Nested bracket access: var["key1"]["key2"] → var.key1.key2
100+ # var["key"] → var.key (nested bracket access to dot notation)
59101 defp convert_bracket_access ( { template , warnings } ) do
60- # Repeat to handle deeply nested
61102 result = template
62103 |> do_bracket_to_dot ( )
63104 |> do_bracket_to_dot ( )
64105 |> do_bracket_to_dot ( )
106+ |> do_bracket_to_dot ( )
65107
66108 { result , warnings }
67109 end
@@ -74,80 +116,135 @@ defmodule Beacon.Template.HEExConverter do
74116 )
75117 end
76118
77- # <.link navigate={path}>text </.link> → <a href={path}>text </a>
119+ # <.link navigate={...}>... </.link> → <a href="...">... </a>
78120 defp convert_phoenix_links ( { template , warnings } ) do
79- # navigate= links
80- result = Regex . replace (
81- ~r/ <\. link\s +navigate=\{ ([^}]+)\} \s *>/ ,
82- template ,
83- "<a href={\\ 1}>"
84- )
85-
86- # navigate="path" links
87- result = Regex . replace (
88- ~r/ <\. link\s +navigate="([^"]+)"\s *>/ ,
89- result ,
90- "<a href=\" \\ 1\" >"
91- )
92-
93- # href= links
94- result = Regex . replace (
95- ~r/ <\. link\s +href=\{ ([^}]+)\} \s *>/ ,
96- result ,
97- "<a href={\\ 1}>"
98- )
99-
100- # With class and navigate
101- result = Regex . replace (
102- ~r/ <\. link\s +class="([^"]+)"\s +navigate=\{ ([^}]+)\} \s *>/ ,
103- result ,
104- "<a class=\" \\ 1\" href={\\ 2}>"
105- )
121+ result = template
122+ # Handle various <.link> attribute orderings
123+ |> then ( fn t ->
124+ Regex . replace ( ~r/ <\. link\s +([^>]*?)navigate=\{ ([^}]+)\} ([^>]*)>/ , t , fn _ , before , path , after_ ->
125+ attrs = String . trim ( "#{ before } #{ after_ } " )
126+ if attrs == "" do
127+ "<a href=\" {{ #{ String . trim ( path ) } }}\" >"
128+ else
129+ "<a #{ attrs } href=\" {{ #{ String . trim ( path ) } }}\" >"
130+ end
131+ end )
132+ end )
133+ |> then ( fn t ->
134+ Regex . replace ( ~r/ <\. link\s +([^>]*?)navigate="([^"]+)"([^>]*)>/ , t , fn _ , before , path , after_ ->
135+ attrs = String . trim ( "#{ before } #{ after_ } " )
136+ if attrs == "" do
137+ "<a href=\" #{ path } \" >"
138+ else
139+ "<a #{ attrs } href=\" #{ path } \" >"
140+ end
141+ end )
142+ end )
143+ |> then ( fn t ->
144+ Regex . replace ( ~r/ <\. link\s +([^>]*?)href=\{ ([^}]+)\} ([^>]*)>/ , t , fn _ , before , path , after_ ->
145+ attrs = String . trim ( "#{ before } #{ after_ } " )
146+ if attrs == "" do
147+ "<a href=\" {{ #{ String . trim ( path ) } }}\" >"
148+ else
149+ "<a #{ attrs } href=\" {{ #{ String . trim ( path ) } }}\" >"
150+ end
151+ end )
152+ end )
153+ |> String . replace ( "</.link>" , "</a>" )
106154
107- result = Regex . replace (
108- ~r/ <\. link\s +class="([^"]+)"\s +navigate="([^"]+)"\s *>/ ,
109- result ,
110- "<a class=\" \\ 1\" href=\" \\ 2\" >"
111- )
155+ { result , warnings }
156+ end
112157
113- # Close tags
114- result = String . replace ( result , "</.link>" , "</a>" )
158+ # phx-submit → @submit, phx-click → @click
159+ defp convert_phx_events ( { template , warnings } ) do
160+ result = template
161+ |> then ( & Regex . replace ( ~r/ phx-submit="([^"]+)"/ , & 1 , "@submit=\" \\ 1\" " ) )
162+ |> then ( & Regex . replace ( ~r/ phx-click="([^"]+)"/ , & 1 , "@click=\" \\ 1\" " ) )
163+ |> then ( & Regex . replace ( ~r/ phx-change="([^"]+)"/ , & 1 , "@change=\" \\ 1\" " ) )
115164
116165 { result , warnings }
117166 end
118167
119- # Simple conditionals: <%= if expr do %>...<% end %> → :if on wrapper
168+ # <%= if expr do %>...<% end %> — flag for manual conversion
120169 defp convert_simple_conditionals ( { template , warnings } ) do
121- # This is a best-effort conversion for simple cases
122- # Complex multi-line conditionals are flagged for manual review
123- { template , warnings }
170+ # Count if/end blocks
171+ if_count = length ( Regex . scan ( ~r/ <%=?\s *if\s / , template ) )
172+
173+ new_warnings = if if_count > 0 do
174+ [ "#{ if_count } if/end block(s) need manual conversion to :if/:else directives" ]
175+ else
176+ [ ]
177+ end
178+
179+ { template , warnings ++ new_warnings }
124180 end
125181
126- # Simple loops: <%= for x <- list do %>...<% end %> → : for on wrapper
182+ # <%= for x <- list do %>...<% end %> — flag for manual conversion
127183 defp convert_simple_loops ( { template , warnings } ) do
128- { template , warnings }
184+ for_count = length ( Regex . scan ( ~r/ <%=?\s *for\s / , template ) )
185+
186+ new_warnings = if for_count > 0 do
187+ [ "#{ for_count } for/end block(s) need manual conversion to :for directives" ]
188+ else
189+ [ ]
190+ end
191+
192+ { template , warnings ++ new_warnings }
129193 end
130194
131- # Flag Elixir function calls that can't be auto-converted
132- defp flag_function_calls ( { template , warnings } ) do
133- # Find Module.function() calls
134- modules = Regex . scan ( ~r/ ([A-Z][a-zA-Z.]+\. [a-z_]+\( [^)]*\) )/ , template )
195+ # <%= expr %> → {{ expr }} for remaining output tags
196+ defp convert_eex_output_tags ( { template , warnings } ) do
197+ result = Regex . replace (
198+ ~r/ <%=\s *(.+?)\s *%>/ s ,
199+ template ,
200+ fn _ , expr ->
201+ expr = String . trim ( expr )
202+ "{{ #{ expr } }}"
203+ end
204+ )
135205
136- new_warnings = Enum . map ( modules , fn [ full | _ ] ->
137- "Function call needs manual migration to resolver: #{ String . slice ( full , 0 , 80 ) } "
138- end )
206+ { result , warnings }
207+ end
208+
209+ # Clean up remaining EEx tags
210+ defp cleanup_eex_remnants ( { template , warnings } ) do
211+ # <% end %> → remove (handled by :if/:for directives)
212+ result = Regex . replace ( ~r/ \s *<%\s *end\s *%>\s */ , template , "\n " )
213+
214+ # <% code %> non-output tags → flag
215+ remaining = Regex . scan ( ~r/ <%[^=](.+?)%>/ s , result )
139216
140- { template , warnings ++ Enum . uniq ( new_warnings ) }
217+ new_warnings = if length ( remaining ) > 0 do
218+ [ "#{ length ( remaining ) } non-output EEx tag(s) (<% ... %>) need manual review" ]
219+ else
220+ [ ]
221+ end
222+
223+ { result , warnings ++ new_warnings }
141224 end
142225
143- # Flag my_component calls
144- defp flag_my_component_calls ( { template , warnings } ) do
145- components = Regex . scan ( ~r/ my_component\( "([^"]+)"/ , template )
226+ # Flag anything that couldn't be auto-converted
227+ defp flag_remaining_issues ( { template , warnings } ) do
228+ new_warnings = [ ]
229+
230+ # Module function calls still present
231+ modules = Regex . scan ( ~r/ ([A-Z][a-zA-Z.]+\. [a-z_]+\( [^)]*\) )/ , template )
232+ new_warnings = new_warnings ++ Enum . map ( modules , fn [ full | _ ] ->
233+ "Remaining function call: #{ String . slice ( full , 0 , 80 ) } "
234+ end )
146235
147- new_warnings = Enum . map ( components , fn [ _ , name ] ->
148- "my_component(\" #{ name } \" ) call needs component expansion"
236+ # my_component calls
237+ components = Regex . scan ( ~r/ my_component\( "([^"]+)"/ , template )
238+ new_warnings = new_warnings ++ Enum . map ( components , fn [ _ , name ] ->
239+ "my_component(\" #{ name } \" ) needs component expansion"
149240 end )
150241
151- { template , warnings ++ Enum . uniq ( new_warnings ) }
242+ # raw() calls
243+ if Regex . match? ( ~r/ raw\( / , template ) do
244+ new_warnings = [ "raw() HTML injection needs manual review" | new_warnings ]
245+ { template , warnings ++ Enum . uniq ( new_warnings ) }
246+ else
247+ { template , warnings ++ Enum . uniq ( new_warnings ) }
248+ end
152249 end
153250end
0 commit comments