Skip to content

Commit bfff583

Browse files
committed
Wire Beacon syntax into publish/render pipeline
publish_page now supports format: :beacon alongside format: :heex. Beacon syntax path: template → Parser.parse → ComponentExpander.expand → AST stored in ETS render_page checks for AST first, falls back to legacy IR HEEx path unchanged (backward compatible). Extracted shared helpers: store_page_metadata, store_page_handlers, update_site_css_candidates, build_component_registry. 9 new integration tests: static, expressions, conditionals, loops, filters, dynamic attrs, events, HEEx backward compat, complex blog pattern. 503 tests, 0 failures.
1 parent 04e0a68 commit bfff583

2 files changed

Lines changed: 258 additions & 24 deletions

File tree

lib/beacon/runtime_renderer.ex

Lines changed: 102 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,49 @@ defmodule Beacon.RuntimeRenderer do
4141
def publish_page(site, page_id, attrs) when is_atom(site) and is_binary(page_id) do
4242
template = Map.fetch!(attrs, :template)
4343
path = Map.get(attrs, :path, "/")
44+
format = Map.get(attrs, :format, :heex)
4445

46+
if format == :beacon do
47+
publish_page_beacon(site, page_id, template, attrs)
48+
else
49+
publish_page_heex(site, page_id, template, path, attrs)
50+
end
51+
end
52+
53+
defp publish_page_beacon(site, page_id, template, attrs) do
54+
path = Map.get(attrs, :path, "/")
55+
56+
# 1. Parse Beacon template syntax to platform-agnostic AST
57+
page_ast = Beacon.Template.Parser.parse(template)
58+
59+
# 2. Expand components into the AST
60+
component_registry = build_component_registry(site)
61+
expanded_ast = Beacon.Template.ComponentExpander.expand(page_ast, component_registry)
62+
63+
# 3. Store the AST as JSON in ETS
64+
:ets.insert(@table, {{site, page_id, :ast}, expanded_ast})
65+
66+
# 4. Store manifest and route
67+
store_page_metadata(site, page_id, attrs)
68+
69+
# 5. Store event handlers
70+
store_page_handlers(site, page_id, attrs)
71+
72+
# 6. CSS candidates from the template source
73+
candidates = Beacon.CSS.CandidateExtractor.extract(template)
74+
:ets.insert(@table, {{site, page_id, :css_candidates}, candidates})
75+
update_site_css_candidates(site, candidates)
76+
77+
# 7. Register page dependencies
78+
Beacon.PageRenderCache.register_page_deps(site, page_id, %{
79+
layout_id: Map.get(attrs, :layout_id),
80+
components: MapSet.new()
81+
})
82+
83+
:ok
84+
end
85+
86+
defp publish_page_heex(site, page_id, template, path, attrs) do
4587
# 1. Compile HEEx to AST via standard Phoenix pipeline
4688
env = Beacon.Web.PageLive.make_env(site)
4789
{:ok, ast} = Beacon.Template.HEEx.compile(site, path, template)
@@ -50,7 +92,28 @@ defmodule Beacon.RuntimeRenderer do
5092
ir = extract_ir(ast, env)
5193
:ets.insert(@table, {{site, page_id, :ir}, :erlang.term_to_binary(ir)})
5294

53-
# 3. Store the page manifest — everything needed to mount and render
95+
# 3. Store metadata, handlers, CSS
96+
store_page_metadata(site, page_id, attrs)
97+
store_page_handlers(site, page_id, attrs)
98+
99+
candidates = Beacon.CSS.CandidateExtractor.extract(template)
100+
:ets.insert(@table, {{site, page_id, :css_candidates}, candidates})
101+
update_site_css_candidates(site, candidates)
102+
103+
# 4. Register page dependencies for cascade invalidation
104+
component_names = Beacon.PageRenderCache.extract_component_names(ir)
105+
106+
Beacon.PageRenderCache.register_page_deps(site, page_id, %{
107+
layout_id: Map.get(attrs, :layout_id),
108+
components: component_names
109+
})
110+
111+
:ok
112+
end
113+
114+
defp store_page_metadata(site, page_id, attrs) do
115+
path = Map.get(attrs, :path, "/")
116+
54117
manifest = %{
55118
id: page_id,
56119
site: site,
@@ -65,15 +128,13 @@ defmodule Beacon.RuntimeRenderer do
65128
}
66129

67130
:ets.insert(@table, {{site, page_id, :manifest}, manifest})
68-
69-
# 4. Register route: path → page_id (for mount-time lookup)
70131
:ets.insert(@table, {{site, :route, path}, page_id})
71132

72-
# 5. Store custom page assigns
73133
static_assigns = Map.get(attrs, :assigns, %{})
74134
:ets.insert(@table, {{site, page_id, :assigns}, static_assigns})
135+
end
75136

76-
# 6. Store event handlers — parse to AST at publish time, not runtime
137+
defp store_page_handlers(site, page_id, attrs) do
77138
handlers = Map.get(attrs, :event_handlers, [])
78139

79140
for %{name: name, code: code} <- handlers do
@@ -84,19 +145,16 @@ defmodule Beacon.RuntimeRenderer do
84145
handler_names = Enum.map(handlers, & &1.name)
85146
:ets.insert(@table, {{site, page_id, :handler_index}, handler_names})
86147

87-
# 8. Store page helpers (dynamic_helper calls)
88148
helpers = Map.get(attrs, :helpers, [])
89149

90150
for helper <- helpers do
91151
helper_ast = Code.string_to_quoted!(helper.code)
92152
args_ast = Code.string_to_quoted!(helper.args)
93153
:ets.insert(@table, {{site, page_id, :helper, helper.name}, :erlang.term_to_binary(%{code: helper_ast, args: args_ast})})
94154
end
155+
end
95156

96-
# 9. Extract CSS candidates and track for conditional recompilation
97-
candidates = Beacon.CSS.CandidateExtractor.extract(template)
98-
:ets.insert(@table, {{site, page_id, :css_candidates}, candidates})
99-
157+
defp update_site_css_candidates(site, candidates) do
100158
known =
101159
case :ets.lookup(@table, {site, :css_candidates}) do
102160
[{_, existing}] -> existing
@@ -109,16 +167,25 @@ defmodule Beacon.RuntimeRenderer do
109167
updated = MapSet.union(known, new_classes)
110168
:ets.insert(@table, {{site, :css_candidates}, updated})
111169
end
170+
end
112171

113-
# 10. Register page dependencies for cascade invalidation
114-
component_names = Beacon.PageRenderCache.extract_component_names(ir)
115-
116-
Beacon.PageRenderCache.register_page_deps(site, page_id, %{
117-
layout_id: manifest.layout_id,
118-
components: component_names
119-
})
172+
defp build_component_registry(site) do
173+
# Build a map of component name → AST from stored component templates
174+
case :ets.match(@table, {{site, :component, :"$1"}, :"$2"}) do
175+
matches when is_list(matches) ->
176+
Map.new(matches, fn [name, binary] ->
177+
component = :erlang.binary_to_term(binary)
178+
component_ast =
179+
case component do
180+
%{ast: ast} when is_list(ast) -> ast
181+
_ -> []
182+
end
183+
{to_string(name), component_ast}
184+
end)
120185

121-
:ok
186+
_ ->
187+
%{}
188+
end
122189
end
123190

124191
# ---------------------------------------------------------------------------
@@ -1058,15 +1125,26 @@ defmodule Beacon.RuntimeRenderer do
10581125
# ---------------------------------------------------------------------------
10591126

10601127
def render_page(site, page_id, assigns \\ %{}) when is_atom(site) do
1061-
case :ets.lookup(@table, {site, page_id, :ir}) do
1062-
[{_, serialized_ir}] ->
1063-
ir = :erlang.binary_to_term(serialized_ir)
1128+
# Try AST-based rendering first (new platform-agnostic format)
1129+
case :ets.lookup(@table, {site, page_id, :ast}) do
1130+
[{_, ast}] when is_list(ast) ->
10641131
stored_assigns = fetch_assigns(site, page_id)
10651132
full_assigns = Map.merge(stored_assigns, assigns) |> Map.delete(:__changed__)
1066-
{:ok, render_ir(ir, full_assigns)}
1133+
rendered = Beacon.Client.LiveViewCompiler.render(ast, full_assigns)
1134+
{:ok, rendered}
10671135

1068-
[] ->
1069-
{:error, :not_found}
1136+
_ ->
1137+
# Fall back to legacy IR-based rendering (HEEx format)
1138+
case :ets.lookup(@table, {site, page_id, :ir}) do
1139+
[{_, serialized_ir}] ->
1140+
ir = :erlang.binary_to_term(serialized_ir)
1141+
stored_assigns = fetch_assigns(site, page_id)
1142+
full_assigns = Map.merge(stored_assigns, assigns) |> Map.delete(:__changed__)
1143+
{:ok, render_ir(ir, full_assigns)}
1144+
1145+
[] ->
1146+
{:error, :not_found}
1147+
end
10701148
end
10711149
end
10721150

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
defmodule Beacon.Template.BeaconSyntaxIntegrationTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Beacon.RuntimeRenderer
5+
6+
@site :my_site
7+
8+
setup do
9+
RuntimeRenderer.init()
10+
11+
on_exit(fn ->
12+
if :ets.whereis(:beacon_runtime_poc) != :undefined do
13+
:ets.delete_all_objects(:beacon_runtime_poc)
14+
end
15+
end)
16+
17+
:ok
18+
end
19+
20+
describe "beacon syntax publish and render" do
21+
test "simple template with static content" do
22+
RuntimeRenderer.publish_page(@site, "beacon_1", %{
23+
template: "<h1>Hello World</h1>",
24+
path: "/beacon-test",
25+
format: :beacon
26+
})
27+
28+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_1")
29+
assert rendered =~ "Hello World"
30+
assert rendered =~ "<h1>"
31+
end
32+
33+
test "template with expression binding" do
34+
RuntimeRenderer.publish_page(@site, "beacon_2", %{
35+
template: "<p>{{ greeting }}</p>",
36+
path: "/beacon-expr",
37+
format: :beacon
38+
})
39+
40+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_2", %{"greeting" => "Hi there"})
41+
assert rendered =~ "Hi there"
42+
end
43+
44+
test "template with conditional" do
45+
RuntimeRenderer.publish_page(@site, "beacon_3", %{
46+
template: ~s(<div :if="show">Visible</div><div :else>Hidden</div>),
47+
path: "/beacon-cond",
48+
format: :beacon
49+
})
50+
51+
{:ok, shown} = RuntimeRenderer.render_to_string(@site, "beacon_3", %{"show" => true})
52+
assert shown =~ "Visible"
53+
refute shown =~ "Hidden"
54+
55+
{:ok, hidden} = RuntimeRenderer.render_to_string(@site, "beacon_3", %{"show" => false})
56+
assert hidden =~ "Hidden"
57+
refute hidden =~ "Visible"
58+
end
59+
60+
test "template with loop" do
61+
RuntimeRenderer.publish_page(@site, "beacon_4", %{
62+
template: ~s(<ul><li :for="item in items">{{ item.name }}</li></ul>),
63+
path: "/beacon-loop",
64+
format: :beacon
65+
})
66+
67+
assigns = %{"items" => [%{"name" => "A"}, %{"name" => "B"}, %{"name" => "C"}]}
68+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_4", assigns)
69+
70+
assert rendered =~ "A"
71+
assert rendered =~ "B"
72+
assert rendered =~ "C"
73+
end
74+
75+
test "template with filter" do
76+
RuntimeRenderer.publish_page(@site, "beacon_5", %{
77+
template: ~s(<p>{{ title | upcase }}</p>),
78+
path: "/beacon-filter",
79+
format: :beacon
80+
})
81+
82+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_5", %{"title" => "hello"})
83+
assert rendered =~ "HELLO"
84+
end
85+
86+
test "template with dynamic attribute" do
87+
RuntimeRenderer.publish_page(@site, "beacon_6", %{
88+
template: ~s(<a :href="post.url">Click</a>),
89+
path: "/beacon-attr",
90+
format: :beacon
91+
})
92+
93+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_6", %{"post" => %{"url" => "/blog/hello"}})
94+
assert rendered =~ ~s(href="/blog/hello")
95+
end
96+
97+
test "template with event binding" do
98+
RuntimeRenderer.publish_page(@site, "beacon_7", %{
99+
template: ~s(<button @click="submit_form">Go</button>),
100+
path: "/beacon-event",
101+
format: :beacon
102+
})
103+
104+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_7")
105+
assert rendered =~ ~s(phx-click="submit_form")
106+
end
107+
108+
test "heex format still works (backward compatible)" do
109+
RuntimeRenderer.publish_page(@site, "heex_1", %{
110+
template: ~S(<h1><%= assigns[:name] || "World" %></h1>),
111+
path: "/heex-test",
112+
format: :heex
113+
})
114+
115+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "heex_1", %{name: "Brian"})
116+
assert rendered =~ "Brian"
117+
end
118+
119+
test "complex blog-like template" do
120+
RuntimeRenderer.publish_page(@site, "beacon_blog", %{
121+
template: """
122+
<main>
123+
<h1 :if="filter == 'all'">Blog</h1>
124+
<h1 :else>{{ pretty_filter }}</h1>
125+
<div :for="post in posts">
126+
<h3>{{ post.title }}</h3>
127+
<p>{{ post.date | format_date: "%B %Y" }}</p>
128+
<a :href="post.post_path">Read more</a>
129+
</div>
130+
</main>
131+
""",
132+
path: "/beacon-blog",
133+
format: :beacon
134+
})
135+
136+
assigns = %{
137+
"filter" => "all",
138+
"pretty_filter" => "Elixir",
139+
"posts" => [
140+
%{"title" => "Post 1", "date" => "2023-12-05T00:00:00Z", "post_path" => "/blog/post-1"},
141+
%{"title" => "Post 2", "date" => "2024-01-15T00:00:00Z", "post_path" => "/blog/post-2"}
142+
]
143+
}
144+
145+
{:ok, rendered} = RuntimeRenderer.render_to_string(@site, "beacon_blog", assigns)
146+
147+
assert rendered =~ "Blog"
148+
refute rendered =~ "Elixir"
149+
assert rendered =~ "Post 1"
150+
assert rendered =~ "Post 2"
151+
assert rendered =~ "December 2023"
152+
assert rendered =~ "January 2024"
153+
assert rendered =~ ~s(href="/blog/post-1")
154+
end
155+
end
156+
end

0 commit comments

Comments
 (0)