defmodule PhoenixKit.Modules.Publishing.PageBuilder.Parser do @moduledoc """ Parses PHK (PhoenixKit) XML-style markup into an AST. Example input: ```xml Welcome to PhoenixKit Build faster with {{framework}} Get Started ``` Output AST (one node per top-level element): ```elixir %{ type: :headline, attributes: %{}, content: "Welcome to PhoenixKit" } ``` """ @doc """ Parses PHK XML content into an AST. """ @spec parse(String.t()) :: {:ok, map()} | {:error, term()} def parse(content) when is_binary(content) do content = String.trim(content) case Saxy.parse_string( content, PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler, [] ) do {:ok, ast} -> {:ok, ast} {:error, reason} -> {:error, {:parse_error, reason}} end rescue e -> {:error, {:parse_exception, e}} end def parse(_), do: {:error, :invalid_content} end defmodule PhoenixKit.Modules.Publishing.PageBuilder.SaxHandler do @moduledoc false @behaviour Saxy.Handler def handle_event(:start_document, _prolog, _state) do {:ok, %{stack: [], result: nil}} end def handle_event(:end_document, _data, state) do {:ok, state.result} end def handle_event(:start_element, {name, attributes}, state) do node = %{ type: normalize_tag_name(name), attributes: parse_attributes(attributes), children: [], content: nil } new_state = %{state | stack: [node | state.stack]} {:ok, new_state} end def handle_event(:end_element, _name, %{stack: [current | rest]} = state) do # Simplify node if it only has content and no children simplified = cond do current.children == [] and is_binary(current.content) -> %{ type: current.type, attributes: current.attributes, content: String.trim(current.content) } current.content == nil and current.children != [] -> %{ type: current.type, attributes: current.attributes, children: Enum.reverse(current.children) } true -> %{ type: current.type, attributes: current.attributes, children: Enum.reverse(current.children), content: current.content && String.trim(current.content) } end case rest do [] -> {:ok, %{state | stack: [], result: simplified}} [parent | ancestors] -> updated_parent = %{parent | children: [simplified | parent.children]} {:ok, %{state | stack: [updated_parent | ancestors]}} end end def handle_event(:characters, chars, %{stack: [current | rest]} = state) do trimmed = String.trim(chars) updated_current = if trimmed != "" do case current.content do nil -> %{current | content: chars} existing -> %{current | content: existing <> chars} end else current end {:ok, %{state | stack: [updated_current | rest]}} end def handle_event(:characters, _chars, state) do {:ok, state} end # Normalize tag names to atoms (Headline -> :headline, CTA -> :cta). # # Uses `String.to_existing_atom/1` so user-supplied (admin-edited) PHK # XML content can't keep minting new atoms — the BEAM atom table is # finite (default ~1M, GC-free), so arbitrary unique tags would # eventually OOM-crash the VM. Unknown tags route to `:unknown`, # which the renderer's catch-all `resolve_component(_)` handles. # # The atoms for legitimate PHK components are pre-allocated at compile # time by their resolver clauses in # `PhoenixKit.Modules.Publishing.PageBuilder.Renderer` (`:headline`, # `:subheadline`, `:cta`, `:image`, `:video`, `:entityform`), so # `String.to_existing_atom/1` finds them. New # components added in the future must ship a matching resolver clause # to register their atom — same as today. defp normalize_tag_name(name) do String.to_existing_atom(String.downcase(name)) rescue ArgumentError -> :unknown end # Convert attribute list to map with string keys defp parse_attributes(attrs) do Enum.into(attrs, %{}, fn {key, value} -> {String.downcase(key), value} end) end end