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 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)) """

<#{escape_html(tag_name)}->

#{header} #{rows}
""" |> String.trim() end defp render_header(column) do " #{escape_html(column)}" 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, "") " #{escape_html(value)}" end) """ #{cells} """ |> 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