defmodule Unpoly do @moduledoc """ A Plug adapter and helpers for Unpoly, the unobtrusive JavaScript framework. ## Options * `:cookie_name` - the cookie name where the request method is echoed to. Defaults to `"_up_method"`. * `:cookie_opts` - additional options to pass to method cookie. See `Plug.Conn.put_resp_cookie/4` for all available options. """ @doc """ Alias for `Unpoly.unpoly?/1` """ @spec up?(Plug.Conn.t()) :: boolean() def up?(conn), do: unpoly?(conn) @doc """ Returns whether the current request is a [page fragment update](https://unpoly.com/up.replace) triggered by an Unpoly frontend. This will eventually just check for the `X-Up-Version header`. Just in case a user still has an older version of Unpoly running on the frontend, we also check for the X-Up-Target header. """ @spec unpoly?(Plug.Conn.t()) :: boolean() def unpoly?(conn), do: version(conn) !== nil || target(conn) !== nil @doc """ Returns the current Unpoly version. The version is guaranteed to be set for all Unpoly requests. """ @spec version(Plug.Conn.t()) :: String.t() | nil def version(conn), do: get_req_header(conn, "x-up-version") @doc """ Returns the mode of the targeted layer. Server-side code is free to render different HTML for different modes. For example, you might prefer to not render a site navigation for overlays. """ @doc since: "2.0.0" @spec mode(Plug.Conn.t()) :: String.t() | nil def mode(conn), do: get_req_header(conn, "x-up-mode") @doc """ Returns the mode of the layer targeted for a failed fragment update. A fragment update is considered failed if the server responds with a status code other than 2xx, but still renders HTML. Server-side code is free to render different HTML for different modes. For example, you might prefer to not render a site navigation for overlays. """ @doc since: "2.0.0" @spec fail_mode(Plug.Conn.t()) :: String.t() | nil def fail_mode(conn), do: get_req_header(conn, "x-up-fail-mode") @doc """ Returns the mode of the layer from which the fragment update originated. This is an experimental feature that can be used to determine the context from which a request was made. Returns `nil` if the header is not present. """ @doc since: "2.0.0" @spec origin_mode(Plug.Conn.t()) :: String.t() | nil def origin_mode(conn), do: get_req_header(conn, "x-up-origin-mode") @doc """ Returns the context of the layer targeted for a failed fragment update. This is an experimental feature for handling context in failed updates. Returns an empty map if no context is present. """ @doc since: "2.0.0" @spec fail_context(Plug.Conn.t()) :: map() def fail_context(conn) do case get_req_header(conn, "x-up-fail-context") do nil -> %{} json -> Phoenix.json_library().decode!(json) end end @doc """ Returns the CSS selector for a fragment that Unpoly will update in case of a successful response (200 status code). The Unpoly frontend will expect an HTML response containing an element that matches this selector. Server-side code is free to optimize its successful response by only returning HTML that matches this selector. """ @spec target(Plug.Conn.t()) :: String.t() | nil def target(conn), do: get_req_header(conn, "x-up-target") @doc """ Returns the CSS selector for a fragment that Unpoly will update in case of an failed response. Server errors or validation failures are all examples for a failed response (non-200 status code). The Unpoly frontend will expect an HTML response containing an element that matches this selector. Server-side code is free to optimize its response by only returning HTML that matches this selector. """ @spec fail_target(Plug.Conn.t()) :: String.t() | nil def fail_target(conn), do: get_req_header(conn, "x-up-fail-target") @doc """ Returns the context of the targeted layer as a map. The context is sent by Unpoly in the X-Up-Context request header. It contains data about the layer's state (e.g., game state, wizard step, etc.). Returns an empty map if no context is present. ## Examples context(conn) # => %{"lives" => 3, "level" => 2} """ @doc since: "2.0.0" @spec context(Plug.Conn.t()) :: map() def context(conn) do case get_req_header(conn, "x-up-context") do nil -> %{} json -> Phoenix.json_library().decode!(json) end end @doc """ Returns whether the current layer has context. Returns `true` if the X-Up-Context request header is present and contains context data, `false` otherwise. ## Examples context?(conn) # => true (if context is present) """ @doc since: "2.0.0" @spec context?(Plug.Conn.t()) :: boolean() def context?(conn) do get_req_header(conn, "x-up-context") != nil end @doc """ Returns whether the given CSS selector is targeted by the current fragment update in case of a successful response (200 status code). Note that the matching logic is very simplistic and does not actually know how your page layout is structured. It will return `true` if the tested selector and the requested CSS selector matches exactly, or if the requested selector is `body` or `html`. Always returns `true` if the current request is not an Unpoly fragment update. """ @spec target?(Plug.Conn.t(), String.t()) :: boolean() def target?(conn, tested_target), do: query_target(conn, target(conn), tested_target) @doc """ Returns whether the given CSS selector is targeted by the current fragment update in case of a failed response (non-200 status code). Note that the matching logic is very simplistic and does not actually know how your page layout is structured. It will return `true` if the tested selector and the requested CSS selector matches exactly, or if the requested selector is `body` or `html`. Always returns `true` if the current request is not an Unpoly fragment update. """ @spec fail_target?(Plug.Conn.t(), String.t()) :: boolean() def fail_target?(conn, tested_target), do: query_target(conn, fail_target(conn), tested_target) @doc """ Returns whether the given CSS selector is targeted by the current fragment update for either a success or a failed response. Note that the matching logic is very simplistic and does not actually know how your page layout is structured. It will return `true` if the tested selector and the requested CSS selector matches exactly, or if the requested selector is `body` or `html`. Always returns `true` if the current request is not an Unpoly fragment update. """ @spec any_target?(Plug.Conn.t(), String.t()) :: boolean() def any_target?(conn, tested_target), do: target?(conn, tested_target) || fail_target?(conn, tested_target) @doc """ Returns whether the current form submission should be [validated](https://unpoly.com/input-up-validate) (and not be saved to the database). """ @spec validate?(Plug.Conn.t()) :: boolean() def validate?(conn), do: validate_name(conn) !== nil @doc """ Returns whether the current layer is the root layer. The root layer is the default layer that contains the initial page content. It is identified by the mode "root". Returns `true` if the current layer is the root layer, or if the request is not an Unpoly request (full page load). ## Examples root?(conn) # => true """ @doc since: "2.0.0" @spec root?(Plug.Conn.t()) :: boolean() def root?(conn) do case mode(conn) do nil -> true "root" -> true _ -> false end end @doc """ Returns whether the current layer is an overlay. Overlays are layers that are stacked on top of the root layer, such as modal dialogs, popups, drawers, or covers. Returns `false` if the current layer is the root layer, or if the request is not an Unpoly request (full page load). ## Examples overlay?(conn) # => true (for modes like "modal", "popup", "drawer", "cover") """ @doc since: "2.0.0" @spec overlay?(Plug.Conn.t()) :: boolean() def overlay?(conn) do case mode(conn) do nil -> false "root" -> false _ -> true end end @doc """ If the current form submission is a [validation](https://unpoly.com/input-up-validate), this returns the name attribute of the form field that has triggered the validation. """ @spec validate_name(Plug.Conn.t()) :: String.t() | nil def validate_name(conn), do: get_req_header(conn, "x-up-validate") @doc """ Returns the timestamp of an existing fragment that is being reloaded. The timestamp must be explicitely set by the user as an [up-time] attribute on the fragment. It should indicate the time when the fragment's underlying data was last changed. """ @doc since: "2.0.0" @spec reload_from_time(Plug.Conn.t()) :: String.t() | nil def reload_from_time(conn) do with timestamp when is_binary(timestamp) <- get_req_header(conn, "x-up-reload-from-time"), {timestamp, ""} <- Integer.parse(timestamp), {:ok, datetime} <- DateTime.from_unix(timestamp) do datetime else _ -> nil end end @doc """ Returns the timestamp of an existing fragment that is being reloaded. The timestamp must be explicitely set by the user as an [up-time] attribute on the fragment. It should indicate the time when the fragment's underlying data was last changed. """ @doc since: "2.0.0" @spec reload?(Plug.Conn.t()) :: boolean() def reload?(conn), do: reload_from_time(conn) !== nil @doc """ Forces Unpoly to use the given string as the document title when processing this response. This is useful when you skip rendering the `` in an Unpoly request. """ @spec put_title(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_title(conn, new_title), do: Plug.Conn.put_resp_header(conn, "x-up-title", new_title) @doc """ Expires cache entries matching the given URL pattern. Expired cache entries will be revalidated when accessed. Use "*" to expire all cache entries. Use "false" to prevent automatic cache expiration after non-GET requests. ## Examples Unpoly.expire_cache(conn, "/notes/*") Unpoly.expire_cache(conn, "*") Unpoly.expire_cache(conn, "false") """ @doc since: "2.0.0" @spec expire_cache(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def expire_cache(conn, pattern) do put_resp_expire_cache_header(conn, pattern) end @doc """ Evicts (removes) cache entries matching the given URL pattern. Evicted cache entries are completely removed from the cache. Use "*" to evict all cache entries. ## Examples Unpoly.evict_cache(conn, "/notes/*") Unpoly.evict_cache(conn, "*") """ @doc since: "2.0.0" @spec evict_cache(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def evict_cache(conn, pattern) do put_resp_evict_cache_header(conn, pattern) end @doc """ Prevents automatic cache expiration after this non-GET request. By default, Unpoly expires the entire cache after non-GET requests. This helper prevents that behavior. ## Examples Unpoly.keep_cache(conn) """ @doc since: "2.0.0" @spec keep_cache(Plug.Conn.t()) :: Plug.Conn.t() def keep_cache(conn) do put_resp_expire_cache_header(conn, "false") end @doc """ Updates the layer context in the response. The context will be merged with the existing layer context on the client. To remove a context key, set its value to nil. ## Examples Unpoly.put_context(conn, %{lives: 2}) Unpoly.put_context(conn, %{removed_key: nil}) """ @doc since: "2.0.0" @spec put_context(Plug.Conn.t(), map()) :: Plug.Conn.t() def put_context(conn, context) when is_map(context) do put_resp_context_header(conn, context) end @doc """ Forces the response to open in a new overlay layer with the given options. This is useful for server-side code that wants to force a response to open in an overlay, even when the request was not made from an overlay. ## Examples Unpoly.open_layer(conn, %{mode: "modal"}) Unpoly.open_layer(conn, %{mode: "drawer", size: "large"}) """ @doc since: "2.0.0" @spec open_layer(Plug.Conn.t(), map()) :: Plug.Conn.t() def open_layer(conn, options) when is_map(options) do put_resp_open_layer_header(conn, options) end @doc """ Emits one or more JavaScript events on the frontend. Events are sent via the X-Up-Events response header and will be triggered on the document when the response is received. You can pass either a single event type (string) or a map of events with their properties. ## Examples # Emit a simple event without properties Unpoly.emit_events(conn, "user:created") # Emit an event with properties Unpoly.emit_events(conn, %{"user:created" => %{id: 123, name: "Alice"}}) # Emit multiple events Unpoly.emit_events(conn, %{ "user:created" => %{id: 123}, "notification:show" => %{message: "User created"} }) """ @doc since: "2.0.0" @spec emit_events(Plug.Conn.t(), String.t() | map()) :: Plug.Conn.t() def emit_events(conn, event_type) when is_binary(event_type) do emit_events(conn, %{event_type => %{}}) end def emit_events(conn, events) when is_map(events) do put_resp_events_header(conn, events) end # Plug def init(opts \\ []) do cookie_name = Keyword.get(opts, :cookie_name, "_up_method") cookie_opts = Keyword.get(opts, :cookie_opts, http_only: false) {cookie_name, cookie_opts} end def call(conn, {cookie_name, cookie_opts}) do conn |> Plug.Conn.fetch_cookies() |> append_method_cookie(cookie_name, cookie_opts) end @doc """ Sets the value of the "X-Up-Accept-Layer" response header. """ @doc since: "2.0.0" @spec put_resp_accept_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() def put_resp_accept_layer_header(conn, value) when is_binary(value) do Plug.Conn.put_resp_header(conn, "x-up-accept-layer", value) end def put_resp_accept_layer_header(conn, value) do value = Phoenix.json_library().encode_to_iodata!(value) put_resp_accept_layer_header(conn, to_string(value)) end @doc """ Sets the value of the "X-Up-Dismiss-Layer" response header. """ @doc since: "2.0.0" @spec put_resp_dismiss_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() def put_resp_dismiss_layer_header(conn, value) when is_binary(value) do Plug.Conn.put_resp_header(conn, "x-up-dismiss-layer", value) end def put_resp_dismiss_layer_header(conn, value) do value = Phoenix.json_library().encode_to_iodata!(value) put_resp_dismiss_layer_header(conn, to_string(value)) end @doc """ Sets the value of the "X-Up-Events" response header. """ @doc since: "2.0.0" @spec put_resp_events_header(Plug.Conn.t(), term) :: Plug.Conn.t() def put_resp_events_header(conn, value) when is_binary(value) do Plug.Conn.put_resp_header(conn, "x-up-events", value) end def put_resp_events_header(conn, value) do value = Phoenix.json_library().encode_to_iodata!(value) put_resp_events_header(conn, to_string(value)) end @doc """ Sets the value of the "X-Up-Location" response header. """ @spec put_resp_location_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_resp_location_header(conn, value) do Plug.Conn.put_resp_header(conn, "x-up-location", value) end @doc """ Sets the value of the "X-Up-Method" response header. """ @spec put_resp_method_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_resp_method_header(conn, value) do Plug.Conn.put_resp_header(conn, "x-up-method", value) end @doc """ Sets the value of the "X-Up-Target" response header. """ @spec put_resp_target_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_resp_target_header(conn, value) do Plug.Conn.put_resp_header(conn, "x-up-target", value) end @doc """ Sets the value of the "X-Up-Evict-Cache" response header. The client will evict cached responses that match the given URL pattern. Use "*" to evict all cached entries. ## Examples Unpoly.put_resp_evict_cache_header(conn, "/notes/*") Unpoly.put_resp_evict_cache_header(conn, "*") """ @doc since: "2.0.0" @spec put_resp_evict_cache_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_resp_evict_cache_header(conn, value) do Plug.Conn.put_resp_header(conn, "x-up-evict-cache", value) end @doc """ Sets the value of the "X-Up-Expire-Cache" response header. The client will expire cached responses that match the given URL pattern, forcing revalidation on next access. Use "*" to expire all cached entries. Use "false" to prevent automatic cache expiration after non-GET requests. ## Examples Unpoly.put_resp_expire_cache_header(conn, "/notes/*") Unpoly.put_resp_expire_cache_header(conn, "*") Unpoly.put_resp_expire_cache_header(conn, "false") """ @doc since: "2.0.0" @spec put_resp_expire_cache_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_resp_expire_cache_header(conn, value) do Plug.Conn.put_resp_header(conn, "x-up-expire-cache", value) end @doc """ Sets the value of the "X-Up-Context" response header. The client will update the layer context with the given value. Use this to modify the layer's context from the server side. ## Examples Unpoly.put_resp_context_header(conn, %{lives: 2}) """ @doc since: "2.0.0" @spec put_resp_context_header(Plug.Conn.t(), term) :: Plug.Conn.t() def put_resp_context_header(conn, value) when is_binary(value) do Plug.Conn.put_resp_header(conn, "x-up-context", value) end def put_resp_context_header(conn, value) do value = Phoenix.json_library().encode_to_iodata!(value) put_resp_context_header(conn, to_string(value)) end @doc """ Sets the value of the "X-Up-Open-Layer" response header. The client will open a new overlay layer with the given options. This is useful for forcing a response to open in an overlay. ## Examples Unpoly.put_resp_open_layer_header(conn, %{mode: "modal", size: "large"}) """ @doc since: "2.0.0" @spec put_resp_open_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() def put_resp_open_layer_header(conn, value) when is_binary(value) do Plug.Conn.put_resp_header(conn, "x-up-open-layer", value) end def put_resp_open_layer_header(conn, value) do value = Phoenix.json_library().encode_to_iodata!(value) put_resp_open_layer_header(conn, to_string(value)) end defp append_method_cookie(conn, cookie_name, cookie_opts) do cond do conn.method != "GET" && !up?(conn) -> Plug.Conn.put_resp_cookie(conn, cookie_name, conn.method, cookie_opts) Map.has_key?(conn.req_cookies, "_up_method") -> Plug.Conn.delete_resp_cookie(conn, cookie_name, cookie_opts) true -> conn end end ## Helpers defp get_req_header(conn, key), do: Plug.Conn.get_req_header(conn, key) |> List.first() defp query_target(conn, actual_target, tested_target) do if up?(conn) do cond do actual_target == tested_target -> true actual_target == "html" -> true actual_target == "body" && tested_target not in ["head", "title", "meta"] -> true true -> false end else true end end end