301 lines
9.2 KiB
Elixir
301 lines
9.2 KiB
Elixir
defmodule Forum.Forms do
|
|
@moduledoc """
|
|
Owns the parsed forms document and supervises one QuickBEAM runtime
|
|
per element. `DynamicSupervisor` so the tree can change at runtime.
|
|
|
|
State (parsed Floki tree) lives in the ETS table `:forum_forms`,
|
|
owned by the supervisor process. Reads are direct ETS lookups — no
|
|
GenServer round-trip — and the table dies cleanly with the supervisor.
|
|
|
|
Public lifecycle:
|
|
Forum.Forms.path/0 — path to the root forms.html
|
|
Forum.Forms.reload/0 — re-read priv/forms.html and network forms
|
|
Forum.Forms.set_html/1 — replace HTML directly and reconcile
|
|
Forum.Forms.add_form/2 — start one runtime ad-hoc
|
|
Forum.Forms.remove_form/2 — stop the runtime keyed by {class, id}
|
|
Forum.Forms.reconcile/0 — diff stored elements vs running runtimes
|
|
Forum.Forms.html/0 — rendered root HTML generated from the Floki tree
|
|
Forum.Forms.html/1 — rendered network HTML generated from that network tree
|
|
Forum.Forms.tree/0 — parsed Floki tree
|
|
Forum.Forms.elements/0 — descriptor maps for <forms-> children
|
|
"""
|
|
use DynamicSupervisor
|
|
require Logger
|
|
|
|
@table :forum_forms
|
|
|
|
# ── boot ──────────────────────────────────────────────────────────
|
|
|
|
def start_link(_opts) do
|
|
DynamicSupervisor.start_link(__MODULE__, :ok, name: __MODULE__)
|
|
end
|
|
|
|
@impl true
|
|
def init(:ok) do
|
|
:ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
|
DynamicSupervisor.init(strategy: :one_for_one)
|
|
end
|
|
|
|
# ── reads ─────────────────────────────────────────────────────────
|
|
|
|
def html do
|
|
render_tree(root_tree())
|
|
end
|
|
|
|
def html(network_slug) when is_binary(network_slug) do
|
|
network_slug
|
|
|> network_tree()
|
|
|> render_tree()
|
|
end
|
|
|
|
def tree do
|
|
case :ets.lookup(@table, :tree) do
|
|
[{:tree, tree}] -> tree
|
|
[] -> []
|
|
end
|
|
end
|
|
|
|
def root_tree do
|
|
case :ets.lookup(@table, :root_tree) do
|
|
[{:root_tree, tree}] -> tree
|
|
[] -> []
|
|
end
|
|
end
|
|
|
|
def network_tree(network_slug) do
|
|
case :ets.lookup(@table, {:network_tree, network_slug}) do
|
|
[{{:network_tree, ^network_slug}, tree}] -> tree
|
|
[] -> root_tree()
|
|
end
|
|
end
|
|
|
|
def elements do
|
|
tree()
|
|
|> Floki.find("forms- > *")
|
|
|> Enum.map(&to_descriptor/1)
|
|
end
|
|
|
|
def path do
|
|
Forum.Assets.path("forms.html")
|
|
end
|
|
|
|
def network_paths do
|
|
"networks/*/index.html"
|
|
|> Forum.Assets.paths()
|
|
|> Enum.map(fn path -> {path |> Path.dirname() |> Path.basename(), path} end)
|
|
|> Enum.sort()
|
|
end
|
|
|
|
# ── mutations ─────────────────────────────────────────────────────
|
|
|
|
@doc "Re-read priv/forms.html and priv/networks/*/index.html, store them, reconcile runtimes."
|
|
def reload do
|
|
root_tree = path() |> File.read!() |> Floki.parse_fragment!()
|
|
|
|
network_trees =
|
|
Enum.map(network_paths(), fn {slug, path} ->
|
|
{slug, path |> File.read!() |> Floki.parse_fragment!()}
|
|
end)
|
|
|
|
all_elements =
|
|
[root_tree | Enum.map(network_trees, fn {_slug, tree} -> tree end)]
|
|
|> Enum.flat_map(&Floki.find(&1, "forms- > *"))
|
|
|
|
:ets.insert(@table, [
|
|
{:tree, wrap_forms(all_elements)},
|
|
{:root_tree, root_tree}
|
|
| Enum.map(network_trees, fn {slug, tree} -> {{:network_tree, slug}, tree} end)
|
|
])
|
|
|
|
reconcile()
|
|
end
|
|
|
|
@doc "Replace the canonical HTML with `raw` and reconcile runtimes."
|
|
def set_html(raw) when is_binary(raw) do
|
|
tree = Floki.parse_fragment!(raw)
|
|
:ets.insert(@table, [{:tree, tree}, {:root_tree, tree}])
|
|
reconcile()
|
|
end
|
|
|
|
@doc "Start one supervised form runtime under `{class_name, id}`."
|
|
def add_form(class_name, data) when is_binary(class_name) and is_map(data) do
|
|
DynamicSupervisor.start_child(__MODULE__, child_spec_for(class_name, data))
|
|
end
|
|
|
|
@doc "Stop the form runtime registered under `{class_name, id}`."
|
|
def remove_form(class_name, id) do
|
|
case Registry.lookup(Forum.ProcessRegistry, {:form, {class_name, id}}) do
|
|
[{pid, _}] -> DynamicSupervisor.terminate_child(__MODULE__, pid)
|
|
[] -> {:error, :not_found}
|
|
end
|
|
end
|
|
|
|
# ── reconciliation ────────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Diffs stored elements against the currently-running form runtimes:
|
|
stops runtimes for elements that disappeared, starts runtimes for
|
|
elements that appeared. Same `{class, id}` keeps its existing
|
|
runtime even if the element's data changed — call `remove_form/2 +
|
|
add_form/2` (or set new HTML containing a different id) to force a
|
|
refresh.
|
|
"""
|
|
def reconcile do
|
|
desired =
|
|
Map.new(elements(), fn el ->
|
|
class = pascal_case(el["tag"])
|
|
data = Map.put_new(el["attrs"], "content", el["text"])
|
|
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
|
|
{{class, id}, {class, data}}
|
|
end)
|
|
|
|
current_keys = MapSet.new(current_form_keys())
|
|
desired_keys = MapSet.new(Map.keys(desired))
|
|
|
|
for {class, id} <- MapSet.difference(current_keys, desired_keys) do
|
|
remove_form(class, id)
|
|
end
|
|
|
|
for key <- MapSet.difference(desired_keys, current_keys) do
|
|
{class, data} = Map.fetch!(desired, key)
|
|
add_form(class, data)
|
|
end
|
|
|
|
Logger.info("Forum.Forms: reconciled, target=#{map_size(desired)} runtimes")
|
|
:ok
|
|
end
|
|
|
|
defp current_form_keys do
|
|
for {_id, pid, _type, _mods} <- DynamicSupervisor.which_children(__MODULE__),
|
|
is_pid(pid),
|
|
{:form, key} <- Registry.keys(Forum.ProcessRegistry, pid),
|
|
do: key
|
|
end
|
|
|
|
# ── child spec ────────────────────────────────────────────────────
|
|
|
|
defp child_spec_for(class_name, data) do
|
|
id = Map.get(data, "id") || :erlang.unique_integer([:positive])
|
|
name = {:via, Registry, {Forum.ProcessRegistry, {:form, {class_name, id}}}}
|
|
|
|
{QuickBEAM,
|
|
name: name,
|
|
id: {class_name, id},
|
|
define: %{"className" => class_name, "formData" => data},
|
|
script: Forum.Assets.path("js/form_init.js")}
|
|
end
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────
|
|
|
|
defp wrap_forms(elements), do: [{"forms-", [], elements}]
|
|
|
|
defp render_tree([]), do: ""
|
|
|
|
defp render_tree(tree) do
|
|
tree
|
|
|> Floki.find("forms- > *")
|
|
|> Enum.map(&to_descriptor/1)
|
|
|> Enum.group_by(& &1["tag"])
|
|
|> Enum.map_join("\n", fn {tag, descriptors} -> render_table(tag, descriptors) end)
|
|
end
|
|
|
|
defp render_table(tag, descriptors) do
|
|
tag_name = String.trim_trailing(tag, "-")
|
|
columns = columns_for(descriptors)
|
|
header = Enum.map_join(columns, "\n", &render_header/1)
|
|
rows = Enum.map_join(descriptors, "\n", &render_row(&1, columns))
|
|
|
|
"""
|
|
<section class="form-table" data-form-tag="#{escape_attr(tag_name)}">
|
|
<h2><code><#{escape_html(tag_name)}-></code></h2>
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
#{header}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
#{rows}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
"""
|
|
|> String.trim()
|
|
end
|
|
|
|
defp render_header(column) do
|
|
" <th>#{escape_html(column)}</th>"
|
|
end
|
|
|
|
defp render_row(descriptor, columns) do
|
|
attrs = descriptor["attrs"]
|
|
|
|
cells =
|
|
Enum.map_join(columns, "\n", fn column ->
|
|
value = if column == "content", do: descriptor["text"], else: Map.get(attrs, column, "")
|
|
" <td>#{escape_html(value)}</td>"
|
|
end)
|
|
|
|
"""
|
|
<tr>
|
|
#{cells}
|
|
</tr>
|
|
"""
|
|
|> String.trim_trailing()
|
|
end
|
|
|
|
defp columns_for(descriptors) do
|
|
attr_columns =
|
|
descriptors
|
|
|> Enum.flat_map(fn descriptor -> Map.keys(descriptor["attrs"]) end)
|
|
|> Enum.uniq()
|
|
|
|
content_columns =
|
|
if Enum.any?(descriptors, &(&1["text"] != "")), do: ["content"], else: []
|
|
|
|
sort_columns(attr_columns) ++ content_columns
|
|
end
|
|
|
|
defp sort_columns(columns) do
|
|
preferred = ["id", "network-id", "name", "title", "email", "key"]
|
|
preferred_columns = Enum.filter(preferred, &(&1 in columns))
|
|
other_columns = columns -- preferred
|
|
|
|
preferred_columns ++ Enum.sort(other_columns)
|
|
end
|
|
|
|
defp to_descriptor({tag, attrs, content}) do
|
|
text =
|
|
content
|
|
|> Enum.filter(&is_binary/1)
|
|
|> Enum.join("")
|
|
|> String.trim()
|
|
|
|
%{"tag" => tag, "attrs" => Map.new(attrs), "text" => text}
|
|
end
|
|
|
|
defp escape_attr(value) do
|
|
value
|
|
|> to_string()
|
|
|> Plug.HTML.html_escape_to_iodata()
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
|
|
defp escape_html(value) do
|
|
value
|
|
|> to_string()
|
|
|> Plug.HTML.html_escape_to_iodata()
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
|
|
defp pascal_case(tag) do
|
|
tag
|
|
|> String.trim_trailing("-")
|
|
|> String.split("-")
|
|
|> Enum.reject(&(&1 == ""))
|
|
|> Enum.map_join("", &String.capitalize/1)
|
|
end
|
|
end
|