init
This commit is contained in:
300
lib/forum/forms.ex
Normal file
300
lib/forum/forms.ex
Normal file
@@ -0,0 +1,300 @@
|
||||
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
|
||||
Reference in New Issue
Block a user