This commit is contained in:
Sam
2026-06-10 11:51:56 -05:00
commit 66ba338b81
57 changed files with 5509 additions and 0 deletions

300
lib/forum/forms.ex Normal file
View 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>&lt;#{escape_html(tag_name)}-&gt;</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