defmodule Unpoly do @moduledoc """ A Plug adapter and helpers for Unpoly, the unobtrusive JavaScript framework. This library provides server-side support for Unpoly 3.0+, enabling seamless fragment updates, layer management, and cache revalidation in Phoenix applications. ## Version Compatibility - **3.0+**: Full support for Unpoly 3.0+ protocol - **2.x**: Support for Unpoly 2.x protocol (maintained for backward compatibility) - Unpoly 2.x clients remain fully supported ## Key Features ### Fragment Updates Read request headers to optimize server responses: - `target/1` - Get the CSS selector being updated - `mode/1` - Get the current layer mode (root, modal, drawer, etc.) - `context/1` - Access layer context data ### Cache Revalidation (New in 3.0) Unpoly 3.0 introduces sophisticated cache revalidation using standard HTTP headers: - `if_modified_since/1` and `if_none_match/1` - Read conditional request headers - `put_last_modified/2` and `put_etag/2` - Set cache validation headers - `put_vary/2` - Specify which request headers influence the response - `cache_fresh?/2` - Check if cached content is still valid - `not_modified/1` - Return a 304 Not Modified response ### Layer Management Control overlay layers from the server: - `accept_layer/2` - Accept and close an overlay with a value - `dismiss_layer/2` - Dismiss and close an overlay with a value - `open_layer/2` - Force opening a new overlay layer ### Cache Control - `expire_cache/2` - Mark cache entries for revalidation - `evict_cache/2` - Remove cache entries completely - `keep_cache/1` - Prevent automatic cache expiration ## Migration from 2.x to 3.0 ### Breaking Changes 1. **X-Up-Title now requires JSON encoding**: The `put_title/2` function now JSON-encodes the title value as required by Unpoly 3.0. 2. **Deprecated headers**: X-Up-Reload-From-Time is deprecated in favor of standard Last-Modified/If-Modified-Since headers. Use the new cache revalidation helpers instead. ### New Features - Full cache revalidation support with ETags and Last-Modified headers - New convenience helpers: `accept_layer/2` and `dismiss_layer/2` - Better cache partitioning with the `Vary` header ## Plug Options When using `Unpoly` as a Plug in your endpoint or router: * `: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. ## Example Usage # In your controller def show(conn, %{"id" => id}) do post = Blog.get_post!(id) etag = "\"post-\#{post.id}-\#{post.updated_at}\"" # Check if the client's cache is still fresh if Unpoly.cache_fresh?(conn, etag: etag) do Unpoly.not_modified(conn) else conn |> Unpoly.put_etag(etag) |> Unpoly.put_vary(["X-Up-Target", "X-Up-Mode"]) |> render("show.html", post: post) end end # Accept an overlay layer from the server def create(conn, params) do case Blog.create_post(params) do {:ok, post} -> conn |> Unpoly.accept_layer(%{id: post.id}) |> put_status(201) |> json(%{success: true}) {:error, changeset} -> conn |> put_status(422) |> render("new.html", changeset: changeset) end end ## Learn More - Unpoly Documentation: https://unpoly.com - Unpoly 3.0 Changes: https://unpoly.com/changes/3.0.0 - Unpoly Protocol: https://unpoly.com/up.protocol - Cache Revalidation: https://unpoly.com/caching """ @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 from the `If-Modified-Since` request header. This is part of Unpoly's cache revalidation system. When a cached fragment has a `Last-Modified` timestamp, Unpoly will send it back in the `If-Modified-Since` header when revalidating the cache. If the fragment hasn't changed since this timestamp, the server can return a 304 Not Modified response using `not_modified/1`. Returns `nil` if the header is not present or cannot be parsed. ## Examples if_modified_since(conn) # => ~U[2024-01-15 10:30:00Z] """ @doc since: "3.0.0" @spec if_modified_since(Plug.Conn.t()) :: DateTime.t() | nil def if_modified_since(conn) do case get_req_header(conn, "if-modified-since") do nil -> nil date_string -> parse_http_date(date_string) end end @doc """ Returns the ETag from the `If-None-Match` request header. This is part of Unpoly's cache revalidation system. When a cached fragment has an ETag, Unpoly will send it back in the `If-None-Match` header when revalidating the cache. If the current ETag matches this value, the server can return a 304 Not Modified response using `not_modified/1`. Returns `nil` if the header is not present. ## Examples if_none_match(conn) # => "\"abc123\"" """ @doc since: "3.0.0" @spec if_none_match(Plug.Conn.t()) :: String.t() | nil def if_none_match(conn), do: get_req_header(conn, "if-none-match") @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. ## Deprecation Notice This header (X-Up-Reload-From-Time) is deprecated in Unpoly 3.0. Instead, use standard HTTP conditional request headers: - Use `put_last_modified/2` to set the `Last-Modified` response header - Use `if_modified_since/1` to check the `If-Modified-Since` request header - Use `not_modified/1` to return a 304 Not Modified response This function is kept for backward compatibility with Unpoly 2.x clients. """ @doc since: "2.0.0" @doc deprecated: "Use standard Last-Modified/If-Modified-Since headers instead" @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 whether the current request is reloading an existing fragment. ## Deprecation Notice This function relies on the deprecated X-Up-Reload-From-Time header. In Unpoly 3.0, use standard HTTP conditional request helpers instead: - Check `if_modified_since/1` for conditional requests - Use `cache_fresh?/2` to determine if cached content is still valid This function is kept for backward compatibility with Unpoly 2.x clients. """ @doc since: "2.0.0" @doc deprecated: "Use if_modified_since/1 or cache_fresh?/2 instead" @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. Note: In Unpoly 3.0+, the title value is JSON-encoded as required by the protocol. """ @spec put_title(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_title(conn, new_title) do encoded_title = new_title |> Phoenix.json_library().encode_to_iodata!() |> to_string() Plug.Conn.put_resp_header(conn, "x-up-title", encoded_title) end @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 @doc """ Accepts the current overlay layer and closes it, optionally passing a value to the parent layer. This is a high-level convenience helper that combines setting the X-Up-Accept-Layer header and preventing rendering of HTML by setting X-Up-Target to ":none". When called without a value (or with `nil`), it simply accepts the overlay. When called with a value (string or map), that value is passed to the parent layer. ## Examples # Accept overlay without a value Unpoly.accept_layer(conn) # Accept overlay with a string value Unpoly.accept_layer(conn, "User was created") # Accept overlay with a structured value Unpoly.accept_layer(conn, %{id: 123, name: "Alice"}) """ @doc since: "3.0.0" @spec accept_layer(Plug.Conn.t(), term()) :: Plug.Conn.t() def accept_layer(conn, value \\ nil) do conn |> put_resp_accept_layer_header(value) |> put_resp_target_header(":none") end @doc """ Dismisses the current overlay layer and closes it, optionally passing a value to the parent layer. This is a high-level convenience helper that combines setting the X-Up-Dismiss-Layer header and preventing rendering of HTML by setting X-Up-Target to ":none". When called without a value (or with `nil`), it simply dismisses the overlay. When called with a value (string or map), that value is passed to the parent layer. ## Examples # Dismiss overlay without a value Unpoly.dismiss_layer(conn) # Dismiss overlay with a string value Unpoly.dismiss_layer(conn, "Operation cancelled") # Dismiss overlay with a structured value Unpoly.dismiss_layer(conn, %{reason: "user_cancelled"}) """ @doc since: "3.0.0" @spec dismiss_layer(Plug.Conn.t(), term()) :: Plug.Conn.t() def dismiss_layer(conn, value \\ nil) do conn |> put_resp_dismiss_layer_header(value) |> put_resp_target_header(":none") end @doc """ Sets the `ETag` response header for cache validation. ETags are identifiers for specific versions of a resource. When a client caches a fragment with an ETag, it will send it back in the `If-None-Match` header when revalidating the cache. If the ETag matches, the server can return a 304 Not Modified response using `not_modified/1`. ## Examples Unpoly.put_etag(conn, "\"abc123\"") Unpoly.put_etag(conn, "\"v2-" <> calculate_hash(content) <> "\"") """ @doc since: "3.0.0" @spec put_etag(Plug.Conn.t(), String.t()) :: Plug.Conn.t() def put_etag(conn, etag) do Plug.Conn.put_resp_header(conn, "etag", etag) end @doc """ Sets the `Last-Modified` response header for cache validation. This header indicates when the resource was last changed. When a client caches a fragment with a Last-Modified timestamp, it will send it back in the `If-Modified-Since` header when revalidating the cache. If the resource hasn't changed since that time, the server can return a 304 Not Modified response using `not_modified/1`. ## Examples Unpoly.put_last_modified(conn, ~U[2024-01-15 10:30:00Z]) Unpoly.put_last_modified(conn, user.updated_at) """ @doc since: "3.0.0" @spec put_last_modified(Plug.Conn.t(), DateTime.t()) :: Plug.Conn.t() def put_last_modified(conn, datetime) do http_date = format_http_date(datetime) Plug.Conn.put_resp_header(conn, "last-modified", http_date) end @doc """ Sets the `Vary` response header for cache partitioning. The Vary header tells the cache which request headers influenced the response. This is critical for proper cache partitioning in Unpoly 3.0. You can pass either a single header name (string) or a list of header names. If called multiple times, headers will be accumulated. ## Examples # Single header Unpoly.put_vary(conn, "X-Up-Target") # Multiple headers Unpoly.put_vary(conn, ["X-Up-Target", "X-Up-Mode"]) # Common Unpoly headers Unpoly.put_vary(conn, ["X-Up-Target", "X-Up-Mode", "X-Up-Context"]) """ @doc since: "3.0.0" @spec put_vary(Plug.Conn.t(), String.t() | list(String.t())) :: Plug.Conn.t() def put_vary(conn, header) when is_binary(header) do put_vary(conn, [header]) end def put_vary(conn, headers) when is_list(headers) do # Get existing Vary header if present existing = Plug.Conn.get_resp_header(conn, "vary") # Combine with new headers all_headers = case existing do [] -> headers [existing_value] -> String.split(existing_value, ", ") ++ headers end # Remove duplicates and join vary_value = all_headers |> Enum.uniq() |> Enum.join(", ") Plug.Conn.put_resp_header(conn, "vary", vary_value) end @doc """ Returns a 304 Not Modified response for cache revalidation. This helper sets the response status to 304, sets X-Up-Target to ":none" (telling Unpoly not to expect any HTML), and halts the connection. Use this after checking conditional request headers like `If-None-Match` or `If-Modified-Since` when the cached content is still valid. ## Examples def show(conn, %{"id" => id}) do post = Posts.get_post!(id) etag = "\"post-\#{post.id}-\#{post.updated_at}\"" if if_none_match(conn) == etag do not_modified(conn) else conn |> put_etag(etag) |> render("show.html", post: post) end end """ @doc since: "3.0.0" @spec not_modified(Plug.Conn.t()) :: Plug.Conn.t() def not_modified(conn) do conn |> Plug.Conn.put_status(304) |> put_resp_target_header(":none") |> Plug.Conn.halt() end @doc """ Checks if the cached content is still fresh based on conditional request headers. This helper simplifies cache revalidation logic by checking both ETag and Last-Modified conditions in one call. Returns `true` if either: - The `If-None-Match` header matches the provided `:etag` - The `If-Modified-Since` header is >= the provided `:last_modified` ## Examples def show(conn, %{"id" => id}) do post = Posts.get_post!(id) if cache_fresh?(conn, last_modified: post.updated_at) do not_modified(conn) else conn |> put_last_modified(post.updated_at) |> render("show.html", post: post) end end # With ETag etag = calculate_etag(content) if cache_fresh?(conn, etag: etag) do not_modified(conn) else conn |> put_etag(etag) |> render(content) end """ @doc since: "3.0.0" @spec cache_fresh?(Plug.Conn.t(), keyword()) :: boolean() def cache_fresh?(conn, opts) do etag_matches = case Keyword.get(opts, :etag) do nil -> false etag -> if_none_match(conn) == etag end last_modified_fresh = case Keyword.get(opts, :last_modified) do nil -> false last_modified -> case if_modified_since(conn) do nil -> false since -> DateTime.compare(last_modified, since) != :gt end end etag_matches || last_modified_fresh 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 # Parse HTTP date format (RFC 7231) # Examples: "Mon, 15 Jan 2024 10:30:00 GMT" defp parse_http_date(date_string) do # HTTP dates are in IMF-fixdate format: "Day, DD Mon YYYY HH:MM:SS GMT" # We'll use a simple regex parser since Elixir doesn't have built-in HTTP date parsing case Regex.run( ~r/^[A-Za-z]{3}, (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/, date_string ) do [_, day, month_name, year, hour, minute, second] -> month = month_name_to_number(month_name) if month do {:ok, datetime} = DateTime.new( Date.new!(String.to_integer(year), month, String.to_integer(day)), Time.new!( String.to_integer(hour), String.to_integer(minute), String.to_integer(second) ), "Etc/UTC" ) datetime else nil end _ -> nil end rescue _ -> nil end defp month_name_to_number("Jan"), do: 1 defp month_name_to_number("Feb"), do: 2 defp month_name_to_number("Mar"), do: 3 defp month_name_to_number("Apr"), do: 4 defp month_name_to_number("May"), do: 5 defp month_name_to_number("Jun"), do: 6 defp month_name_to_number("Jul"), do: 7 defp month_name_to_number("Aug"), do: 8 defp month_name_to_number("Sep"), do: 9 defp month_name_to_number("Oct"), do: 10 defp month_name_to_number("Nov"), do: 11 defp month_name_to_number("Dec"), do: 12 defp month_name_to_number(_), do: nil # Format DateTime as HTTP date (RFC 7231) # Example: "Mon, 15 Jan 2024 10:30:00 GMT" defp format_http_date(datetime) do datetime = DateTime.shift_zone!(datetime, "Etc/UTC") day_name = day_of_week_name(Date.day_of_week(datetime)) month_name = number_to_month_name(datetime.month) "#{day_name}, #{String.pad_leading(Integer.to_string(datetime.day), 2, "0")} #{month_name} #{datetime.year} #{String.pad_leading(Integer.to_string(datetime.hour), 2, "0")}:#{String.pad_leading(Integer.to_string(datetime.minute), 2, "0")}:#{String.pad_leading(Integer.to_string(datetime.second), 2, "0")} GMT" end defp day_of_week_name(1), do: "Mon" defp day_of_week_name(2), do: "Tue" defp day_of_week_name(3), do: "Wed" defp day_of_week_name(4), do: "Thu" defp day_of_week_name(5), do: "Fri" defp day_of_week_name(6), do: "Sat" defp day_of_week_name(7), do: "Sun" defp number_to_month_name(1), do: "Jan" defp number_to_month_name(2), do: "Feb" defp number_to_month_name(3), do: "Mar" defp number_to_month_name(4), do: "Apr" defp number_to_month_name(5), do: "May" defp number_to_month_name(6), do: "Jun" defp number_to_month_name(7), do: "Jul" defp number_to_month_name(8), do: "Aug" defp number_to_month_name(9), do: "Sep" defp number_to_month_name(10), do: "Oct" defp number_to_month_name(11), do: "Nov" defp number_to_month_name(12), do: "Dec" end