Skip to content

Commit 04e0a68

Browse files
committed
Extend HEExConverter with function call replacements and phx-event conversion
1 parent fe8f088 commit 04e0a68

1 file changed

Lines changed: 184 additions & 87 deletions

File tree

Lines changed: 184 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,109 @@
11
defmodule 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
153250
end

0 commit comments

Comments
 (0)