Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement page compilation on demand #330

Merged
merged 13 commits into from
Aug 21, 2023
4 changes: 4 additions & 0 deletions dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,10 @@ seeds = fn ->
<p>From dynamic_helper:</p>
<%= dynamic_helper("upcase", %{name: "beacon"}) %>
</div>

<div>
<p>RANDOM:<%= Enum.random(1..100) %></p>
</div>
</main>
""",
helpers: [
Expand Down
3 changes: 1 addition & 2 deletions lib/beacon/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ defmodule Beacon.Application do
Beacon.Repo
]

# We store routes by order and length so the most visited pages will likely be in the first rows
:ets.new(:beacon_pages, [:ordered_set, :named_table, :public, read_concurrency: true])
Beacon.Router.init()

:ets.new(:beacon_assets, [:set, :named_table, :public, read_concurrency: true])

Expand Down
36 changes: 12 additions & 24 deletions lib/beacon/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ defmodule Beacon.Config do
[
{identifier :: atom(),
fun ::
(Beacon.Template.t(), Beacon.Template.LoadMetadata.t() ->
{:cont, Beacon.Template.t()} | {:halt, Beacon.Template.t()} | {:halt, Exception.t()})}
(template :: String.t(), Beacon.Template.LoadMetadata.t() ->
{:cont, String.t() | Beacon.Template.t()} | {:halt, String.t() | Beacon.Template.t()} | {:halt, Exception.t()})}
]}
]}
| {:render_template,
Expand Down Expand Up @@ -168,14 +168,8 @@ defmodule Beacon.Config do
]

@default_render_template [
{:heex,
[
eval_heex_ast: &Beacon.Template.HEEx.eval_ast/2
]},
{:markdown,
[
eval_heex_ast: &Beacon.Template.HEEx.eval_ast/2
]}
{:heex, []},
{:markdown, []}
]

@default_media_types ["image/jpeg", "image/gif", "image/png", "image/webp", ".pdf"]
Expand Down Expand Up @@ -283,15 +277,14 @@ defmodule Beacon.Config do
load_template: [
{:custom_format,
[
validate: fn template, _metadata -> MyEngine.validate(template) end
validate: fn template, _metadata -> MyEngine.validate(template) end,
build_rendered: fn template, _metadata -> %Phoenix.LiveView.Rendered{static: template} end,
]}
],
render_template: [
{:custom_format,
[
assigns: fn template, %{assigns: assigns} -> MyEngine.parse_to_html(template, assigns) end,
compile: &Beacon.Template.HEEx.compile/2,
eval: &Beacon.Template.HEEx.eval_ast/2
assigns: fn %Phoenix.LiveView.Rendered{static: template} , %{assigns: assigns} -> MyEngine.parse_to_html(template, assigns) end
]}
],
after_publish_page: [
Expand Down Expand Up @@ -329,20 +322,15 @@ defmodule Beacon.Config do
compile_heex: &Beacon.Template.HEEx.compile/2
],
custom_format: [
validate: #Function<41.3316493/2 in :erl_eval.expr/6>
validate: #Function<41.3316493/2 in :erl_eval.expr/6>,
build_rendered: #Function<41.3316494/2 in :erl_eval.expr/6>
]
],
render_template: [
heex: [
eval_heex_ast: &Beacon.Template.HEEx.eval_ast/2
],
markdown: [
eval_heex_ast: &Beacon.Template.HEEx.eval_ast/2
],
heex: [],
markdown: [],
custom_format: [
assigns: #Function<41.3316493/2 in :erl_eval.expr/6>,
compile: &Beacon.Template.HEEx.compile/2,
eval: &Beacon.Template.HEEx.eval_ast/2
assigns: #Function<41.3316493/2 in :erl_eval.expr/6>
]
],
after_create_page: [],
Expand Down
13 changes: 11 additions & 2 deletions lib/beacon/lifecycle/template.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
defmodule Beacon.Lifecycle.Template do
@moduledoc false

require Logger
alias Beacon.Lifecycle
@behaviour Beacon.Lifecycle

Expand Down Expand Up @@ -92,8 +93,16 @@ defmodule Beacon.Lifecycle.Template do

This stage runs in the render callback of the LiveView responsible for displaying the page.
"""
def render_template(site, template, format, context) do
lifecycle = Lifecycle.execute(__MODULE__, site, :render_template, template, sub_key: format, context: context)
@spec render_template(Beacon.Content.Page.t(), module(), map(), Macro.Env.t()) :: Beacon.Template.t()
def render_template(page, page_module, assigns, env) do
template =
case page_module.render(assigns) do
%Phoenix.LiveView.Rendered{} = rendered -> rendered
:not_loaded -> Beacon.Loader.PageModuleLoader.load_page!(page, page_module, assigns)
end

context = [path: page.path, assigns: assigns, env: env]
lifecycle = Lifecycle.execute(__MODULE__, page.site, :render_template, template, sub_key: page.format, context: context)
lifecycle.output
end
end
26 changes: 14 additions & 12 deletions lib/beacon/loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ defmodule Beacon.Loader do
|> Content.list_published_pages()
|> Enum.map(fn page ->
Task.async(fn ->
{:ok, _ast} = Beacon.Loader.PageModuleLoader.load_page!(page)
{:ok, _module, _ast} = Beacon.Loader.PageModuleLoader.load_page!(page)
:ok
end)
end)
Expand All @@ -197,20 +197,22 @@ defmodule Beacon.Loader do
end

@doc false
def layout_module_for_site(site, layout_id) do
prefix = Macro.camelize("layout_#{layout_id}")
module_for_site(site, prefix)
def layout_module_for_site(layout_id) do
module_for_site("Layout#{layout_id}")
end

@doc false
def page_module_for_site(site, page_id) do
prefix = Macro.camelize("page_#{page_id}")
module_for_site(site, prefix)
def page_module_for_site(page_id) do
module_for_site("Page#{page_id}")
end

defp module_for_site(site, prefix) do
site_hash = :crypto.hash(:md5, Atom.to_string(site)) |> Base.encode16()
Module.concat([BeaconWeb.LiveRenderer, "#{prefix}#{site_hash}"])
defp module_for_site(resource) do
Module.concat([BeaconWeb.LiveRenderer, resource])
end

defp module_for_site(site, resource) do
site_hash = :md5 |> :crypto.hash(Atom.to_string(site)) |> Base.encode16()
Module.concat([BeaconWeb.LiveRenderer, "#{site_hash}#{resource}"])
end

# This retry logic exists because a module may be in the process of being reloaded, in which case we want to retry
Expand Down Expand Up @@ -358,7 +360,7 @@ defmodule Beacon.Loader do
:ok <- load_snippet_helpers(page.site),
{:ok, _ast} <- Beacon.Loader.LayoutModuleLoader.load_layout!(layout),
:ok <- load_stylesheets(page.site),
{:ok, _ast} <- Beacon.Loader.PageModuleLoader.load_page!(page) do
{:ok, _module, _ast} <- Beacon.Loader.PageModuleLoader.load_page!(page) do
:ok = Beacon.PubSub.page_loaded(page)
:ok
else
Expand All @@ -368,7 +370,7 @@ defmodule Beacon.Loader do

@doc false
def do_unload_page(page) do
module = page_module_for_site(page.site, page.id)
module = page_module_for_site(page.id)
:code.delete(module)
:code.purge(module)
Beacon.Router.del_page(page.site, page.path)
Expand Down
2 changes: 1 addition & 1 deletion lib/beacon/loader/layout_module_loader.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Beacon.Loader.LayoutModuleLoader do

def load_layout!(%Content.Layout{} = layout) do
component_module = Loader.component_module_for_site(layout.site)
module = Loader.layout_module_for_site(layout.site, layout.id)
module = Loader.layout_module_for_site(layout.id)
render_function = render_layout(layout)
ast = render(module, render_function, component_module)
:ok = Loader.reload_module!(module, ast)
Expand Down
148 changes: 114 additions & 34 deletions lib/beacon/loader/page_module_loader.ex
Original file line number Diff line number Diff line change
@@ -1,56 +1,58 @@
defmodule Beacon.Loader.PageModuleLoader do
@moduledoc false

use GenServer

alias Beacon.Content
alias Beacon.Lifecycle
alias Beacon.Loader

require Logger

def load_page!(%Content.Page{} = page) do
component_module = Loader.component_module_for_site(page.site)
page_module = Loader.page_module_for_site(page.site, page.id)
def start_link(config) do
GenServer.start_link(__MODULE__, config, name: name(config.site))
end

# Group function heads together to avoid compiler warnings
functions = [
for fun <- [&page_assigns/1, &handle_event/1, &helper/1] do
fun.(page)
end,
dynamic_helper()
]
def name(site) do
Beacon.Registry.via({site, __MODULE__})
end

ast = render(page_module, component_module, functions)
store_page(page, page_module, component_module)
:ok = Loader.reload_module!(page_module, ast)
{:ok, ast}
def init(config) do
{:ok, config}
end

defp render(module_name, component_module, functions) do
quote do
defmodule unquote(module_name) do
use Phoenix.HTML
import Phoenix.Component
unquote(Loader.maybe_import_my_component(component_module, functions))
def load_page!(%Content.Page{} = page, stage \\ :boot) do
config = Beacon.Config.fetch!(page.site)

unquote_splicing(functions)
stage =
if Code.ensure_loaded?(Mix.Project) and Mix.env() in [:test] do
:request
else
stage
end
end
end

defp store_page(page, page_module, component_module) do
%{id: page_id, layout_id: layout_id, format: format, site: site, path: path} = page
GenServer.call(name(config.site), {:load_page!, page, stage}, 300_000)
end

# [primary_template, {weight_variant_1, template_variant_1}, {...}, ...]
templates = [Lifecycle.Template.load_template(page) | load_variants(page)]
# TODO: retry
def load_page!(%Content.Page{} = page, page_module, assigns) do
Logger.debug("compiling #{page_module}")

Beacon.Router.add_page(site, path, {page_id, layout_id, format, templates, page_module, component_module})
%Content.Page{} = page = Beacon.Content.get_published_page(page.site, page.id)
{:ok, ^page_module, _ast} = load_page!(page, :request)
%Phoenix.LiveView.Rendered{} = rendered = page_module.render(assigns)
rendered
end

defp load_variants(page) do
%{variants: variants} = Beacon.Repo.preload(page, :variants)
defp build(module_name, component_module, functions) do
quote do
defmodule unquote(module_name) do
use Phoenix.HTML
import Phoenix.Component
unquote(Loader.maybe_import_my_component(component_module, functions))

for variant <- variants do
{variant.weight, Lifecycle.Template.load_template(%{page | template: variant.template})}
unquote_splicing(functions)
end
end
end

Expand Down Expand Up @@ -163,6 +165,64 @@ defmodule Beacon.Loader.PageModuleLoader do
end)
end

defp render(_page, :boot) do
quote do
def render(var!(assigns)) when is_map(var!(assigns)) do
_ = var!(assigns)
:not_loaded
end
end
end

defp render(page, :request) do
primary = Lifecycle.Template.load_template(page)
variants = load_variants(page)

case variants do
[] ->
quote do
def render(var!(assigns)) when is_map(var!(assigns)) do
[primary] = templates(var!(assigns))
primary
end

def templates(var!(assigns)) when is_map(var!(assigns)) do
[unquote(primary)]
end
end

variants ->
quote do
def render(var!(assigns)) when is_map(var!(assigns)) do
var!(assigns)
|> templates()
|> Beacon.Template.choose_template()
end

def templates(var!(assigns)) when is_map(var!(assigns)) do
[
unquote(primary)
| for [name, weight, template] <- unquote(variants) do
{weight, template}
end
]
end
end
end
end

defp load_variants(page) do
%{variants: variants} = Beacon.Repo.preload(page, :variants)

for variant <- variants do
[
variant.name,
variant.weight,
Lifecycle.Template.load_template(%{page | template: variant.template})
]
end
end

defp dynamic_helper do
quote do
def dynamic_helper(helper_name, args) do
Expand All @@ -177,11 +237,31 @@ defmodule Beacon.Loader.PageModuleLoader do
path
|> String.split("/")
|> Enum.map(&path_segment_to_arg(&1, prefix))

# |> String.replace(",|", " |")
end

defp path_segment_to_arg(":" <> segment, prefix), do: prefix <> segment
defp path_segment_to_arg("*" <> segment, prefix), do: "| " <> prefix <> segment
defp path_segment_to_arg(segment, _prefix), do: segment

## Callbacks

def handle_call({:load_page!, page, stage}, _from, config) do
component_module = Loader.component_module_for_site(page.site)
page_module = Loader.page_module_for_site(page.id)

# Group function heads together to avoid compiler warnings
functions = [
for fun <- [&page_assigns/1, &handle_event/1, &helper/1] do
fun.(page)
end,
render(page, stage),
dynamic_helper()
]

ast = build(page_module, component_module, functions)
:ok = Loader.reload_module!(page_module, ast)
Beacon.Router.add_page(page.site, page.path, {page.id, page.layout_id, page.format, page_module, component_module})

{:reply, {:ok, page_module, ast}, config}
end
end
Loading