defmodule Annotai do @moduledoc """ Dev-only Plug. Mount it in your endpoint, guarded so it never reaches prod: if Mix.env() == :dev do plug Annotai end For requests under `/annotai/*` it serves the widget asset, the annotation API, and the MCP endpoint (via `Annotai.Router`). For every other HTML response it injects a single `) end # Serializes the `:position` config into `data-annotai-*` attributes the widget # reads to place itself. Emits nothing (keeping the injected tag byte-identical to # the no-config case) when unset. h/v are separate attributes so a multi-token value # like `calc(20px + 10px)` survives intact. defp position_attrs do case Application.get_env(:annotai, :position) do nil -> "" cfg -> case resolve_position(cfg) do {:ok, %{corner: corner, h: h, v: v}} -> ~s( data-annotai-corner="#{corner}" data-annotai-inset-h="#{h}" data-annotai-inset-v="#{v}") {:error, reason} -> Logger.warning("[annotai] invalid :position config (#{reason}); using default placement") "" end end end @doc false # Resolves a `:position` keyword list into the anchor corner ("bottom-right") and # the horizontal/vertical edge insets, applying defaults and validating the axes. @spec resolve_position(term()) :: {:ok, %{corner: String.t(), h: String.t(), v: String.t()}} | {:error, String.t()} def resolve_position(cfg) do if Keyword.keyword?(cfg) do with :ok <- validate_position_keys(cfg), :ok <- validate_position_axis(cfg, :top, :bottom), :ok <- validate_position_axis(cfg, :left, :right), {:ok, vside, v} <- resolve_edge(cfg, :top, :bottom), {:ok, hside, h} <- resolve_edge(cfg, :left, :right) do {:ok, %{corner: "#{vside}-#{hside}", h: h, v: v}} end else {:error, "expected a keyword list like [bottom: 20, right: 20]"} end end # Resolves one axis to its active edge + CSS inset: the near edge if named, else the # far edge with its default (so `bottom`/`right` win when the axis is omitted). defp resolve_edge(cfg, near, far) do {side, raw} = if Keyword.has_key?(cfg, near), do: {near, cfg[near]}, else: {far, Keyword.get(cfg, far, @position_default_inset)} with {:ok, css} <- css_length(raw), do: {:ok, to_string(side), css} end defp validate_position_keys(cfg) do case Enum.reject(Keyword.keys(cfg), &(&1 in @position_keys)) do [] -> :ok bad -> {:error, "unknown key(s) #{inspect(bad)}; allowed: #{inspect(@position_keys)}"} end end defp validate_position_axis(cfg, a, b) do if Keyword.has_key?(cfg, a) and Keyword.has_key?(cfg, b), do: {:error, "set only one of #{a}/#{b}"}, else: :ok end defp css_length(n) when is_integer(n), do: {:ok, "#{n}px"} defp css_length(s) when is_binary(s) do case String.trim(s) do "" -> {:error, "empty length string"} trimmed -> {:ok, trimmed} end end defp css_length(other), do: {:error, "invalid length #{inspect(other)}; use an integer or CSS string"} defp html?(conn) do case get_resp_header(conn, "content-type") do [ct | _] -> String.contains?(ct, "text/html") _ -> false end end end