├── test ├── test_helper.exs └── unpoly_test.exs ├── .gitignore ├── .formatter.exs ├── CHANGELOG.md ├── .github ├── dependabot.yml └── workflows │ ├── release-please.yml │ └── test.yml ├── LICENSE ├── mix.exs ├── README.md ├── mix.lock ├── usage-rules.md └── lib └── unpoly.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.ez 2 | /.fetch 3 | /_build/ 4 | /cover/ 5 | /deps/ 6 | /doc/ 7 | /erl_crash.dump 8 | /unpoly-*.tar 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | Every release is documented on the [releases page](../../releases). 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "mix" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | cooldown: 8 | default-days: 7 9 | labels: 10 | - "dependencies" 11 | commit-message: 12 | prefix: "chore" 13 | include: "scope" 14 | 15 | - package-ecosystem: "github-actions" 16 | directory: "/" 17 | schedule: 18 | interval: "weekly" 19 | cooldown: 20 | default-days: 7 21 | labels: 22 | - "dependencies" 23 | commit-message: 24 | prefix: "chore" 25 | include: "scope" 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) The Webstronauts 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | release-please: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | issues: write 13 | pull-requests: write 14 | outputs: 15 | release_created: ${{ steps.release.outputs.release_created }} 16 | steps: 17 | - uses: googleapis/release-please-action@v4 18 | id: release 19 | with: 20 | release-type: elixir 21 | 22 | publish: 23 | needs: [release-please] 24 | runs-on: ubuntu-latest 25 | if: ${{ needs.release-please.outputs.release_created }} 26 | permissions: 27 | contents: write 28 | env: 29 | MIX_ENV: dev 30 | steps: 31 | - uses: actions/checkout@v6 32 | - uses: erlef/setup-beam@v1 33 | id: beam 34 | with: 35 | otp-version: 27.x 36 | elixir-version: 1.18.x 37 | - uses: actions/cache@v4 38 | with: 39 | path: | 40 | deps 41 | _build 42 | key: ${{ runner.os }}-mix-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-${{ hashFiles('**/mix.lock') }} 43 | - run: mix deps.get 44 | - run: mix hex.build 45 | - run: mix hex.publish --yes 46 | env: 47 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 48 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Unpoly.MixProject do 2 | use Mix.Project 3 | 4 | @version "3.0.0" 5 | @description "Plug adapter for Unpoly, the unobtrusive JavaScript framework." 6 | @source_url "https://github.com/webstronauts/ex_unpoly" 7 | 8 | def project do 9 | [ 10 | app: :unpoly, 11 | version: @version, 12 | elixir: "~> 1.14", 13 | aliases: aliases(), 14 | deps: deps(), 15 | 16 | # Hex 17 | package: package(), 18 | description: @description, 19 | 20 | # Docs 21 | name: "Unpoly", 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | extra_applications: [:logger, :plug] 29 | ] 30 | end 31 | 32 | def cli do 33 | [ 34 | preferred_envs: [precommit: :test] 35 | ] 36 | end 37 | 38 | defp deps do 39 | [ 40 | {:ex_doc, "~> 0.30", only: :dev, runtime: false}, 41 | {:jason, "~> 1.0", only: :test}, 42 | {:phoenix, "~> 1.7"}, 43 | {:plug, "~> 1.8"} 44 | ] 45 | end 46 | 47 | defp docs() do 48 | [ 49 | main: "Unpoly", 50 | source_ref: "v#{@version}", 51 | source_url: @source_url 52 | ] 53 | end 54 | 55 | defp package() do 56 | [ 57 | maintainers: ["Robin van der Vleuten"], 58 | licenses: ["MIT"], 59 | links: %{"GitHub" => @source_url}, 60 | files: ~w(lib .formatter.exs mix.exs README.md LICENSE CHANGELOG.md usage-rules.md) 61 | ] 62 | end 63 | 64 | defp aliases do 65 | [ 66 | precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-22.04 12 | env: 13 | MIX_ENV: test 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - elixir: 1.14.x 19 | otp: 24.x 20 | - elixir: 1.18.x 21 | otp: 27.x 22 | lint: lint 23 | steps: 24 | - uses: actions/checkout@v6 25 | - uses: erlef/setup-beam@v1 26 | with: 27 | otp-version: ${{ matrix.otp }} 28 | elixir-version: ${{ matrix.elixir }} 29 | - uses: actions/cache@v4 30 | id: cache-deps 31 | with: 32 | path: | 33 | deps 34 | _build 35 | key: ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('**/mix.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-mix-${{ matrix.elixir }}-${{ matrix.otp }}- 38 | - run: mix do deps.get --check-locked, deps.compile 39 | if: steps.cache-deps.outputs.cache-hit != 'true' 40 | - run: mix format --check-formatted 41 | if: ${{ matrix.lint }} 42 | - run: mix deps.unlock --check-unused 43 | if: ${{ matrix.lint }} 44 | - run: mix compile --warnings-as-errors 45 | if: ${{ matrix.lint }} 46 | - name: Run mix test 47 | run: mix test 48 | 49 | dependabot: 50 | needs: [test] 51 | runs-on: ubuntu-latest 52 | permissions: 53 | pull-requests: write 54 | contents: write 55 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 56 | steps: 57 | - id: metadata 58 | uses: dependabot/fetch-metadata@v2 59 | with: 60 | github-token: "${{ secrets.GITHUB_TOKEN }}" 61 | - run: | 62 | gh pr review --approve "$PR_URL" 63 | gh pr merge --squash --auto "$PR_URL" 64 | env: 65 | PR_URL: ${{github.event.pull_request.html_url}} 66 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ex_unpoly 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/webstronauts/ex_unpoly/test.yml?branch=main&style=flat-square)](https://github.com/webstronauts/ex_unpoly/actions?query=workflow%3Atest) 4 | [![Hex.pm](https://img.shields.io/hexpm/v/unpoly.svg?style=flat-square)](https://hex.pm/packages/unpoly) 5 | [![Hexdocs.pm](https://img.shields.io/badge/hex-docs-blue.svg?style=flat-square)](https://hexdocs.pm/unpoly/) 6 | 7 | A Plug adapter and helpers for Unpoly, the unobtrusive JavaScript framework. 8 | 9 | --- 10 | 11 | Built by 12 | The Webstronauts, go-to agency for challenging ideas and ambitious organisations. 13 | 14 | --- 15 | 16 | ## Installation 17 | 18 | To use Unpoly, you can add it to your application's dependencies. 19 | 20 | ```elixir 21 | def deps do 22 | [ 23 | {:unpoly, "~> 3.0"} 24 | ] 25 | end 26 | ``` 27 | 28 | ## Usage 29 | 30 | You can use the plug within your pipeline. 31 | 32 | ```elixir 33 | defmodule MyApp.Endpoint do 34 | plug Logger 35 | plug Unpoly 36 | plug MyApp.Router 37 | end 38 | ``` 39 | 40 | ### Phoenix Components 41 | 42 | If you're using Phoenix Components (HEEx templates), you'll need to configure the global attribute prefixes to allow `up-` attributes on HTML elements. Add the following to your `lib/my_app_web.ex` file: 43 | 44 | ```elixir 45 | def html do 46 | quote do 47 | use Phoenix.Component, global_prefixes: ~w(up-) 48 | # ... rest of your configuration 49 | end 50 | end 51 | ``` 52 | 53 | This tells Phoenix.Component to accept attributes with the `up-` prefix (like `up-target`, `up-layer`, etc.) in your function components. See the [Phoenix.Component documentation](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-custom-global-attribute-prefixes) for more details. 54 | 55 | --- 56 | 57 | To find out more, head to the [online documentation]([https://hexdocs.pm/ex_unpoly). 58 | 59 | ## Changelog 60 | 61 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 62 | 63 | ## Contributing 64 | 65 | Clone the repository and run `mix test`. To generate docs, run `mix docs`. 66 | 67 | ## Credits 68 | 69 | As it's just a simple port of Ruby to Elixir. All credits should go to the Unpoly team and their [unpoly](https://github.com/unpoly/unpoly) library. 70 | 71 | - [Robin van der Vleuten](https://github.com/robinvdvleuten) 72 | - [All Contributors](../../contributors) 73 | 74 | ## License 75 | 76 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 77 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 | "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 4 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 5 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 7 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 8 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 10 | "phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"}, 11 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, 12 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 13 | "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, 14 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 15 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 16 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 17 | "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, 18 | } 19 | -------------------------------------------------------------------------------- /usage-rules.md: -------------------------------------------------------------------------------- 1 | # Unpoly Usage Rules 2 | 3 | Guidelines for AI assistants when working with ex_unpoly and Unpoly. 4 | 5 | ## Unpoly Attributes 6 | 7 | 1. **Attribute Naming** 8 | - All Unpoly attributes use the `up-` prefix (e.g., `up-target`, `up-layer`, `up-follow`) 9 | - Always use lowercase with hyphens for attribute names 10 | - Never use camelCase or snake_case for Unpoly attributes 11 | 12 | 2. **Phoenix Component Configuration** 13 | - When using Phoenix Components (HEEx), configure global prefixes to allow `up-` attributes 14 | - Add `global_prefixes: ~w(up-)` to the `Phoenix.Component` configuration 15 | - This is required in the `html/0` function in your web module 16 | 17 | 3. **Common Attributes** 18 | - `up-target`: Specifies which fragment to update 19 | - `up-layer`: Opens content in a layer (overlay, modal, drawer) 20 | - `up-follow`: Makes an element follow links with Unpoly 21 | - `up-validate`: Validates a form field on change 22 | - `up-submit`: Makes a form submit via AJAX 23 | - `up-poll`: Polls a fragment for updates 24 | - `up-fragment`: Marks an element as a fragment 25 | 26 | ## Server-Side Response Headers 27 | 28 | 1. **Unpoly Request Detection** 29 | - Check `conn.assigns.unpoly?` to detect Unpoly requests 30 | - The plug automatically parses Unpoly headers and assigns them to conn 31 | - Access Unpoly metadata via `conn.assigns.unpoly` 32 | 33 | 2. **Response Headers** 34 | - Use `Unpoly.context/2` to set context data for the frontend 35 | - Use `Unpoly.target/2` to override the target selector 36 | - Use `Unpoly.layer/2` to configure layer behavior 37 | - Use `Unpoly.events/2` to emit events to the frontend 38 | - These functions return an updated conn with appropriate headers 39 | 40 | 3. **Fragment Rendering** 41 | - Only render the requested fragment for Unpoly requests 42 | - Check `conn.assigns.unpoly.target` to determine what fragment is requested 43 | - Render full pages for non-Unpoly requests 44 | - Use conditional rendering based on `conn.assigns.unpoly?` 45 | 46 | ## Layer Handling 47 | 48 | 1. **Opening Layers** 49 | - Use `up-layer="new"` to open content in a new layer 50 | - Specify layer mode with values: `overlay`, `modal`, `drawer`, `popup`, `cover` 51 | - Configure layer options with `up-size`, `up-class`, `up-dismissable` 52 | 53 | 2. **Closing Layers** 54 | - Return `X-Up-Accept-Layer` header to accept and close a layer 55 | - Return `X-Up-Dismiss-Layer` header to dismiss and close a layer 56 | - Set layer values with `Unpoly.accept_layer/2` or `Unpoly.dismiss_layer/2` 57 | 58 | 3. **Layer Events** 59 | - Listen for layer lifecycle events: `up:layer:open`, `up:layer:opened`, `up:layer:dismissed` 60 | - Handle layer acceptance/dismissal on the server side 61 | - Pass data back to the parent layer via accept/dismiss values 62 | 63 | ## Navigation and History 64 | 65 | 1. **URL Updates** 66 | - Use `up-history="true"` to update the browser URL 67 | - Set `up-history="false"` to prevent URL updates 68 | - Return `X-Up-Location` header to set the URL from the server 69 | - Use `Unpoly.location/2` to set the location header 70 | 71 | 2. **Titles** 72 | - Return `X-Up-Title` header to set the page title 73 | - Use `Unpoly.title/2` to set the title header 74 | - Always provide descriptive titles for better UX 75 | 76 | ## Form Handling 77 | 78 | 1. **Form Submission** 79 | - Use `up-submit` attribute on forms for AJAX submission 80 | - Specify `up-target` to define where the response should be rendered 81 | - Use `up-validate` on inputs for server-side validation 82 | 83 | 2. **Validation Responses** 84 | - Return the form with errors for failed validation 85 | - Return the success fragment for successful submissions 86 | - Use proper HTTP status codes (422 for validation errors, 200 for success) 87 | 88 | 3. **Form State** 89 | - Preserve form state across validation requests 90 | - Use `up-keep` to preserve certain elements during updates 91 | - Handle file uploads properly with multipart forms 92 | 93 | ## Testing 94 | 95 | 1. **Request Headers** 96 | - Set `X-Up-Version` header to simulate Unpoly requests 97 | - Set `X-Up-Target` header to specify the requested fragment 98 | - Set `X-Up-Mode` header to indicate layer mode 99 | 100 | 2. **Response Assertions** 101 | - Assert on Unpoly response headers (e.g., `X-Up-Target`, `X-Up-Events`) 102 | - Verify fragment-only responses for Unpoly requests 103 | - Test both Unpoly and full-page request scenarios 104 | 105 | 3. **Integration Tests** 106 | - Test layer opening and closing 107 | - Verify form validation and submission 108 | - Test navigation and history updates 109 | 110 | ## Performance 111 | 112 | 1. **Fragment Optimization** 113 | - Only render the minimal HTML needed for the requested fragment 114 | - Avoid rendering the full layout for Unpoly requests 115 | - Use conditional rendering to optimize response size 116 | 117 | 2. **Preloading** 118 | - Use `up-preload` on links to preload content on hover 119 | - Implement caching strategies for frequently accessed fragments 120 | - Consider using `up-cache` to control caching behavior 121 | 122 | 3. **Polling** 123 | - Use `up-poll` judiciously to avoid excessive server load 124 | - Set appropriate polling intervals 125 | - Implement conditional polling based on user activity 126 | 127 | ## Common Patterns 128 | 129 | 1. **Navigation Menu** 130 | - Mark the current page with `up-current` attributes 131 | - Use `up-nav` on navigation containers for automatic current marking 132 | - Implement smooth transitions between pages 133 | 134 | 2. **Modal Workflows** 135 | - Open forms in layers for focused interactions 136 | - Return accept/dismiss responses to close layers 137 | - Pass data back to the parent layer 138 | 139 | 3. **Inline Editing** 140 | - Use `up-validate` for real-time validation 141 | - Update specific fragments without full page reloads 142 | - Provide immediate feedback to users 143 | 144 | ## Error Handling 145 | 146 | 1. **Server Errors** 147 | - Return appropriate HTTP status codes 148 | - Render error fragments for Unpoly requests 149 | - Provide user-friendly error messages 150 | 151 | 2. **Network Errors** 152 | - Implement proper error handling on the frontend 153 | - Use `up:fragment:loaded` event to handle load errors 154 | - Provide fallback behavior for failed requests 155 | 156 | 3. **Validation Errors** 157 | - Return 422 status for validation errors 158 | - Render the form with error messages 159 | - Highlight invalid fields clearly 160 | -------------------------------------------------------------------------------- /test/unpoly_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UnpolyTest do 2 | use ExUnit.Case, async: true 3 | import Plug.Test 4 | import Plug.Conn 5 | 6 | describe "target/1" do 7 | test "returns selector from header" do 8 | target = 9 | conn(:get, "/foo") 10 | |> put_req_header("x-up-target", ".css.selector") 11 | |> Unpoly.target() 12 | 13 | assert ".css.selector" = target 14 | end 15 | 16 | test "returns nil when header not present" do 17 | target = 18 | conn(:get, "/foo") 19 | |> Unpoly.target() 20 | 21 | assert is_nil(target) 22 | end 23 | end 24 | 25 | describe "fail_target/1" do 26 | test "returns selector from header" do 27 | target = 28 | conn(:get, "/foo") 29 | |> put_req_header("x-up-fail-target", ".css.selector") 30 | |> Unpoly.fail_target() 31 | 32 | assert ".css.selector" = target 33 | end 34 | 35 | test "returns nil when header not present" do 36 | target = 37 | conn(:get, "/foo") 38 | |> Unpoly.fail_target() 39 | 40 | assert is_nil(target) 41 | end 42 | end 43 | 44 | describe "context/1" do 45 | test "returns context from header as map" do 46 | context = 47 | conn(:get, "/foo") 48 | |> put_req_header("x-up-context", "{\"lives\":3,\"level\":2}") 49 | |> Unpoly.context() 50 | 51 | assert %{"lives" => 3, "level" => 2} = context 52 | end 53 | 54 | test "returns empty map when header not present" do 55 | context = 56 | conn(:get, "/foo") 57 | |> Unpoly.context() 58 | 59 | assert %{} = context 60 | end 61 | 62 | test "handles nested context data" do 63 | context = 64 | conn(:get, "/foo") 65 | |> put_req_header("x-up-context", "{\"user\":{\"name\":\"Alice\",\"role\":\"admin\"}}") 66 | |> Unpoly.context() 67 | 68 | assert %{"user" => %{"name" => "Alice", "role" => "admin"}} = context 69 | end 70 | end 71 | 72 | describe "context?/1" do 73 | test "returns true when X-Up-Context header is present" do 74 | result = 75 | conn(:get, "/foo") 76 | |> put_req_header("x-up-context", "{\"lives\":3}") 77 | |> Unpoly.context?() 78 | 79 | assert result == true 80 | end 81 | 82 | test "returns false when X-Up-Context header is not present" do 83 | result = 84 | conn(:get, "/foo") 85 | |> Unpoly.context?() 86 | 87 | assert result == false 88 | end 89 | 90 | test "returns true even with empty context object" do 91 | result = 92 | conn(:get, "/foo") 93 | |> put_req_header("x-up-context", "{}") 94 | |> Unpoly.context?() 95 | 96 | assert result == true 97 | end 98 | end 99 | 100 | describe "root?/1" do 101 | test "returns true when mode is root" do 102 | result = 103 | conn(:get, "/foo") 104 | |> put_req_header("x-up-mode", "root") 105 | |> Unpoly.root?() 106 | 107 | assert result == true 108 | end 109 | 110 | test "returns true when no mode header (full page load)" do 111 | result = 112 | conn(:get, "/foo") 113 | |> Unpoly.root?() 114 | 115 | assert result == true 116 | end 117 | 118 | test "returns false when mode is an overlay" do 119 | result = 120 | conn(:get, "/foo") 121 | |> put_req_header("x-up-mode", "modal") 122 | |> Unpoly.root?() 123 | 124 | assert result == false 125 | end 126 | end 127 | 128 | describe "overlay?/1" do 129 | test "returns true when mode is modal" do 130 | result = 131 | conn(:get, "/foo") 132 | |> put_req_header("x-up-mode", "modal") 133 | |> Unpoly.overlay?() 134 | 135 | assert result == true 136 | end 137 | 138 | test "returns true when mode is popup" do 139 | result = 140 | conn(:get, "/foo") 141 | |> put_req_header("x-up-mode", "popup") 142 | |> Unpoly.overlay?() 143 | 144 | assert result == true 145 | end 146 | 147 | test "returns true when mode is drawer" do 148 | result = 149 | conn(:get, "/foo") 150 | |> put_req_header("x-up-mode", "drawer") 151 | |> Unpoly.overlay?() 152 | 153 | assert result == true 154 | end 155 | 156 | test "returns false when mode is root" do 157 | result = 158 | conn(:get, "/foo") 159 | |> put_req_header("x-up-mode", "root") 160 | |> Unpoly.overlay?() 161 | 162 | assert result == false 163 | end 164 | 165 | test "returns false when no mode header (full page load)" do 166 | result = 167 | conn(:get, "/foo") 168 | |> Unpoly.overlay?() 169 | 170 | assert result == false 171 | end 172 | end 173 | 174 | describe "origin_mode/1" do 175 | test "returns mode from header" do 176 | mode = 177 | conn(:get, "/foo") 178 | |> put_req_header("x-up-origin-mode", "modal") 179 | |> Unpoly.origin_mode() 180 | 181 | assert "modal" = mode 182 | end 183 | 184 | test "returns nil when header not present" do 185 | mode = 186 | conn(:get, "/foo") 187 | |> Unpoly.origin_mode() 188 | 189 | assert is_nil(mode) 190 | end 191 | end 192 | 193 | describe "fail_context/1" do 194 | test "returns context from header as map" do 195 | context = 196 | conn(:get, "/foo") 197 | |> put_req_header("x-up-fail-context", "{\"error\":\"validation failed\"}") 198 | |> Unpoly.fail_context() 199 | 200 | assert %{"error" => "validation failed"} = context 201 | end 202 | 203 | test "returns empty map when header not present" do 204 | context = 205 | conn(:get, "/foo") 206 | |> Unpoly.fail_context() 207 | 208 | assert %{} = context 209 | end 210 | end 211 | 212 | describe "reload_from_time/1" do 213 | test "returns parsed timestamp from header" do 214 | timestamp = 215 | conn(:get, "/foo") 216 | |> put_req_header("x-up-reload-from-time", "1608730818") 217 | |> Unpoly.reload_from_time() 218 | 219 | assert ~U[2020-12-23 13:40:18Z] = timestamp 220 | end 221 | 222 | test "returns nil when timestamp is invalid" do 223 | timestamp = 224 | conn(:get, "/foo") 225 | |> put_req_header("x-up-reload-from-time", "foo") 226 | |> Unpoly.reload_from_time() 227 | 228 | assert is_nil(timestamp) 229 | end 230 | 231 | test "returns nil when header is missing" do 232 | timestamp = 233 | conn(:get, "/foo") 234 | |> Unpoly.reload_from_time() 235 | 236 | assert is_nil(timestamp) 237 | end 238 | end 239 | 240 | describe "call/2" do 241 | def url(), do: "https://www.example.com" 242 | 243 | test "appends method cookie to non GET requests" do 244 | conn = 245 | build_conn_for_path("/foo", :post) 246 | |> Unpoly.call(Unpoly.init([])) 247 | 248 | assert %{"_up_method" => %{value: "POST", http_only: false}} = conn.resp_cookies 249 | end 250 | 251 | test "deletes method cookie from GET requests" do 252 | conn = 253 | build_conn_for_path("/foo") 254 | |> put_req_cookie("_up_method", "POST") 255 | |> Unpoly.call(Unpoly.init([])) 256 | 257 | assert %{"_up_method" => %{max_age: 0, http_only: false}} = conn.resp_cookies 258 | end 259 | end 260 | 261 | describe "put_resp_accept_layer_header/2" do 262 | test "sets response header" do 263 | conn = 264 | build_conn_for_path("/foo") 265 | |> Unpoly.put_resp_accept_layer_header("foo") 266 | 267 | assert ["foo"] = get_resp_header(conn, "x-up-accept-layer") 268 | 269 | conn = Unpoly.put_resp_accept_layer_header(conn, %{foo: "bar"}) 270 | assert ["{\"foo\":\"bar\"}"] = get_resp_header(conn, "x-up-accept-layer") 271 | 272 | conn = Unpoly.put_resp_accept_layer_header(conn, nil) 273 | assert ["null"] = get_resp_header(conn, "x-up-accept-layer") 274 | end 275 | end 276 | 277 | describe "put_resp_dismiss_layer_header/2" do 278 | test "sets response header" do 279 | conn = 280 | build_conn_for_path("/foo") 281 | |> Unpoly.put_resp_dismiss_layer_header("foo") 282 | 283 | assert ["foo"] = get_resp_header(conn, "x-up-dismiss-layer") 284 | 285 | conn = Unpoly.put_resp_dismiss_layer_header(conn, %{foo: "bar"}) 286 | assert ["{\"foo\":\"bar\"}"] = get_resp_header(conn, "x-up-dismiss-layer") 287 | 288 | conn = Unpoly.put_resp_dismiss_layer_header(conn, nil) 289 | assert ["null"] = get_resp_header(conn, "x-up-dismiss-layer") 290 | end 291 | end 292 | 293 | describe "put_resp_events_header/2" do 294 | test "sets response header" do 295 | conn = 296 | build_conn_for_path("/foo") 297 | |> Unpoly.put_resp_events_header("foo") 298 | 299 | assert ["foo"] = get_resp_header(conn, "x-up-events") 300 | 301 | conn = Unpoly.put_resp_events_header(conn, %{foo: "bar"}) 302 | assert ["{\"foo\":\"bar\"}"] = get_resp_header(conn, "x-up-events") 303 | end 304 | end 305 | 306 | describe "put_resp_target_header/2" do 307 | test "sets response header" do 308 | conn = 309 | build_conn_for_path("/foo") 310 | |> Unpoly.put_resp_target_header("foo") 311 | 312 | assert ["foo"] = get_resp_header(conn, "x-up-target") 313 | end 314 | end 315 | 316 | describe "put_resp_evict_cache_header/2" do 317 | test "sets response header with URL pattern" do 318 | conn = 319 | build_conn_for_path("/foo") 320 | |> Unpoly.put_resp_evict_cache_header("/notes/*") 321 | 322 | assert ["/notes/*"] = get_resp_header(conn, "x-up-evict-cache") 323 | end 324 | 325 | test "sets response header to evict all cache" do 326 | conn = 327 | build_conn_for_path("/foo") 328 | |> Unpoly.put_resp_evict_cache_header("*") 329 | 330 | assert ["*"] = get_resp_header(conn, "x-up-evict-cache") 331 | end 332 | end 333 | 334 | describe "put_resp_expire_cache_header/2" do 335 | test "sets response header with URL pattern" do 336 | conn = 337 | build_conn_for_path("/foo") 338 | |> Unpoly.put_resp_expire_cache_header("/notes/*") 339 | 340 | assert ["/notes/*"] = get_resp_header(conn, "x-up-expire-cache") 341 | end 342 | 343 | test "sets response header to expire all cache" do 344 | conn = 345 | build_conn_for_path("/foo") 346 | |> Unpoly.put_resp_expire_cache_header("*") 347 | 348 | assert ["*"] = get_resp_header(conn, "x-up-expire-cache") 349 | end 350 | 351 | test "sets response header to false to prevent cache expiration" do 352 | conn = 353 | build_conn_for_path("/foo") 354 | |> Unpoly.put_resp_expire_cache_header("false") 355 | 356 | assert ["false"] = get_resp_header(conn, "x-up-expire-cache") 357 | end 358 | end 359 | 360 | describe "expire_cache/2" do 361 | test "expires cache for URL pattern" do 362 | conn = 363 | build_conn_for_path("/foo") 364 | |> Unpoly.expire_cache("/notes/*") 365 | 366 | assert ["/notes/*"] = get_resp_header(conn, "x-up-expire-cache") 367 | end 368 | 369 | test "expires all cache entries with wildcard" do 370 | conn = 371 | build_conn_for_path("/foo") 372 | |> Unpoly.expire_cache("*") 373 | 374 | assert ["*"] = get_resp_header(conn, "x-up-expire-cache") 375 | end 376 | end 377 | 378 | describe "evict_cache/2" do 379 | test "evicts cache for URL pattern" do 380 | conn = 381 | build_conn_for_path("/foo") 382 | |> Unpoly.evict_cache("/notes/*") 383 | 384 | assert ["/notes/*"] = get_resp_header(conn, "x-up-evict-cache") 385 | end 386 | 387 | test "evicts all cache entries with wildcard" do 388 | conn = 389 | build_conn_for_path("/foo") 390 | |> Unpoly.evict_cache("*") 391 | 392 | assert ["*"] = get_resp_header(conn, "x-up-evict-cache") 393 | end 394 | end 395 | 396 | describe "keep_cache/1" do 397 | test "prevents cache expiration" do 398 | conn = 399 | build_conn_for_path("/foo") 400 | |> Unpoly.keep_cache() 401 | 402 | assert ["false"] = get_resp_header(conn, "x-up-expire-cache") 403 | end 404 | end 405 | 406 | describe "put_resp_context_header/2" do 407 | test "sets response header with map" do 408 | conn = 409 | build_conn_for_path("/foo") 410 | |> Unpoly.put_resp_context_header(%{lives: 2}) 411 | 412 | assert ["{\"lives\":2}"] = get_resp_header(conn, "x-up-context") 413 | end 414 | 415 | test "sets response header with string" do 416 | conn = 417 | build_conn_for_path("/foo") 418 | |> Unpoly.put_resp_context_header("{\"lives\":2}") 419 | 420 | assert ["{\"lives\":2}"] = get_resp_header(conn, "x-up-context") 421 | end 422 | 423 | test "handles nested maps" do 424 | conn = 425 | build_conn_for_path("/foo") 426 | |> Unpoly.put_resp_context_header(%{user: %{name: "Alice"}}) 427 | 428 | assert ["{\"user\":{\"name\":\"Alice\"}}"] = get_resp_header(conn, "x-up-context") 429 | end 430 | 431 | test "handles nil values for removing keys" do 432 | conn = 433 | build_conn_for_path("/foo") 434 | |> Unpoly.put_resp_context_header(%{removed_key: nil}) 435 | 436 | assert ["{\"removed_key\":null}"] = get_resp_header(conn, "x-up-context") 437 | end 438 | end 439 | 440 | describe "put_context/2" do 441 | test "updates context with map" do 442 | conn = 443 | build_conn_for_path("/foo") 444 | |> Unpoly.put_context(%{lives: 2}) 445 | 446 | assert ["{\"lives\":2}"] = get_resp_header(conn, "x-up-context") 447 | end 448 | 449 | test "allows removing context keys with nil" do 450 | conn = 451 | build_conn_for_path("/foo") 452 | |> Unpoly.put_context(%{old_key: nil}) 453 | 454 | assert ["{\"old_key\":null}"] = get_resp_header(conn, "x-up-context") 455 | end 456 | end 457 | 458 | describe "put_resp_open_layer_header/2" do 459 | test "sets response header with layer options map" do 460 | conn = 461 | build_conn_for_path("/foo") 462 | |> Unpoly.put_resp_open_layer_header(%{mode: "modal"}) 463 | 464 | assert ["{\"mode\":\"modal\"}"] = get_resp_header(conn, "x-up-open-layer") 465 | end 466 | 467 | test "sets response header with multiple options" do 468 | conn = 469 | build_conn_for_path("/foo") 470 | |> Unpoly.put_resp_open_layer_header(%{mode: "drawer", size: "large"}) 471 | 472 | header = get_resp_header(conn, "x-up-open-layer") |> List.first() 473 | decoded = Jason.decode!(header) 474 | assert %{"mode" => "drawer", "size" => "large"} = decoded 475 | end 476 | 477 | test "sets response header with string" do 478 | conn = 479 | build_conn_for_path("/foo") 480 | |> Unpoly.put_resp_open_layer_header("{\"mode\":\"modal\"}") 481 | 482 | assert ["{\"mode\":\"modal\"}"] = get_resp_header(conn, "x-up-open-layer") 483 | end 484 | end 485 | 486 | describe "open_layer/2" do 487 | test "opens layer with mode option" do 488 | conn = 489 | build_conn_for_path("/foo") 490 | |> Unpoly.open_layer(%{mode: "modal"}) 491 | 492 | assert ["{\"mode\":\"modal\"}"] = get_resp_header(conn, "x-up-open-layer") 493 | end 494 | 495 | test "opens layer with multiple options" do 496 | conn = 497 | build_conn_for_path("/foo") 498 | |> Unpoly.open_layer(%{mode: "drawer", size: "large", class: "custom"}) 499 | 500 | header = get_resp_header(conn, "x-up-open-layer") |> List.first() 501 | decoded = Jason.decode!(header) 502 | assert %{"mode" => "drawer", "size" => "large", "class" => "custom"} = decoded 503 | end 504 | end 505 | 506 | describe "emit_events/2" do 507 | test "emits simple event without properties" do 508 | conn = 509 | build_conn_for_path("/foo") 510 | |> Unpoly.emit_events("user:created") 511 | 512 | header = get_resp_header(conn, "x-up-events") |> List.first() 513 | decoded = Jason.decode!(header) 514 | assert %{"user:created" => %{}} = decoded 515 | end 516 | 517 | test "emits event with properties" do 518 | conn = 519 | build_conn_for_path("/foo") 520 | |> Unpoly.emit_events(%{"user:created" => %{id: 123, name: "Alice"}}) 521 | 522 | header = get_resp_header(conn, "x-up-events") |> List.first() 523 | decoded = Jason.decode!(header) 524 | assert %{"user:created" => %{"id" => 123, "name" => "Alice"}} = decoded 525 | end 526 | 527 | test "emits multiple events" do 528 | conn = 529 | build_conn_for_path("/foo") 530 | |> Unpoly.emit_events(%{ 531 | "user:created" => %{id: 123}, 532 | "notification:show" => %{message: "User created"} 533 | }) 534 | 535 | header = get_resp_header(conn, "x-up-events") |> List.first() 536 | decoded = Jason.decode!(header) 537 | assert %{"user:created" => %{"id" => 123}} = decoded 538 | assert %{"notification:show" => %{"message" => "User created"}} = decoded 539 | end 540 | end 541 | 542 | describe "put_title/2" do 543 | test "sets X-Up-Title header with JSON-encoded value" do 544 | conn = 545 | build_conn_for_path("/foo") 546 | |> Unpoly.put_title("My Page Title") 547 | 548 | assert ["\"My Page Title\""] = get_resp_header(conn, "x-up-title") 549 | end 550 | 551 | test "JSON-encodes special characters" do 552 | conn = 553 | build_conn_for_path("/foo") 554 | |> Unpoly.put_title("Title with \"quotes\" and \\ backslash") 555 | 556 | header = get_resp_header(conn, "x-up-title") |> List.first() 557 | # The header should be a JSON-encoded string 558 | decoded = Jason.decode!(header) 559 | assert "Title with \"quotes\" and \\ backslash" = decoded 560 | end 561 | 562 | test "handles Unicode characters" do 563 | conn = 564 | build_conn_for_path("/foo") 565 | |> Unpoly.put_title("Título con ñ y émojis 🎉") 566 | 567 | header = get_resp_header(conn, "x-up-title") |> List.first() 568 | decoded = Jason.decode!(header) 569 | assert "Título con ñ y émojis 🎉" = decoded 570 | end 571 | 572 | test "handles empty string" do 573 | conn = 574 | build_conn_for_path("/foo") 575 | |> Unpoly.put_title("") 576 | 577 | assert ["\"\""] = get_resp_header(conn, "x-up-title") 578 | end 579 | end 580 | 581 | describe "if_modified_since/1" do 582 | test "returns parsed DateTime from If-Modified-Since header" do 583 | result = 584 | conn(:get, "/foo") 585 | |> put_req_header("if-modified-since", "Mon, 15 Jan 2024 10:30:00 GMT") 586 | |> Unpoly.if_modified_since() 587 | 588 | assert %DateTime{} = result 589 | assert result.year == 2024 590 | assert result.month == 1 591 | assert result.day == 15 592 | assert result.hour == 10 593 | assert result.minute == 30 594 | assert result.second == 0 595 | end 596 | 597 | test "returns nil when header is not present" do 598 | result = 599 | conn(:get, "/foo") 600 | |> Unpoly.if_modified_since() 601 | 602 | assert is_nil(result) 603 | end 604 | 605 | test "returns nil when header has invalid format" do 606 | result = 607 | conn(:get, "/foo") 608 | |> put_req_header("if-modified-since", "invalid date") 609 | |> Unpoly.if_modified_since() 610 | 611 | assert is_nil(result) 612 | end 613 | end 614 | 615 | describe "if_none_match/1" do 616 | test "returns ETag from If-None-Match header" do 617 | result = 618 | conn(:get, "/foo") 619 | |> put_req_header("if-none-match", "\"abc123\"") 620 | |> Unpoly.if_none_match() 621 | 622 | assert "\"abc123\"" = result 623 | end 624 | 625 | test "returns nil when header is not present" do 626 | result = 627 | conn(:get, "/foo") 628 | |> Unpoly.if_none_match() 629 | 630 | assert is_nil(result) 631 | end 632 | 633 | test "handles unquoted ETags" do 634 | result = 635 | conn(:get, "/foo") 636 | |> put_req_header("if-none-match", "abc123") 637 | |> Unpoly.if_none_match() 638 | 639 | assert "abc123" = result 640 | end 641 | end 642 | 643 | describe "put_etag/2" do 644 | test "sets ETag response header" do 645 | conn = 646 | build_conn_for_path("/foo") 647 | |> Unpoly.put_etag("\"abc123\"") 648 | 649 | assert ["\"abc123\""] = get_resp_header(conn, "etag") 650 | end 651 | 652 | test "sets ETag with different value" do 653 | conn = 654 | build_conn_for_path("/foo") 655 | |> Unpoly.put_etag("\"v2-xyz789\"") 656 | 657 | assert ["\"v2-xyz789\""] = get_resp_header(conn, "etag") 658 | end 659 | end 660 | 661 | describe "put_last_modified/2" do 662 | test "sets Last-Modified response header with HTTP date format" do 663 | datetime = ~U[2024-01-15 10:30:00Z] 664 | 665 | conn = 666 | build_conn_for_path("/foo") 667 | |> Unpoly.put_last_modified(datetime) 668 | 669 | assert ["Mon, 15 Jan 2024 10:30:00 GMT"] = get_resp_header(conn, "last-modified") 670 | end 671 | 672 | test "formats datetime in GMT" do 673 | datetime = ~U[2024-06-20 14:30:00Z] 674 | 675 | conn = 676 | build_conn_for_path("/foo") 677 | |> Unpoly.put_last_modified(datetime) 678 | 679 | header = get_resp_header(conn, "last-modified") |> List.first() 680 | assert header =~ "GMT" 681 | assert header =~ "2024" 682 | assert header =~ "Jun" 683 | end 684 | 685 | test "handles different dates" do 686 | datetime = ~U[2023-12-31 23:59:59Z] 687 | 688 | conn = 689 | build_conn_for_path("/foo") 690 | |> Unpoly.put_last_modified(datetime) 691 | 692 | assert ["Sun, 31 Dec 2023 23:59:59 GMT"] = get_resp_header(conn, "last-modified") 693 | end 694 | end 695 | 696 | describe "put_vary/2" do 697 | test "sets Vary header with single header name" do 698 | conn = 699 | build_conn_for_path("/foo") 700 | |> Unpoly.put_vary("X-Up-Target") 701 | 702 | assert ["X-Up-Target"] = get_resp_header(conn, "vary") 703 | end 704 | 705 | test "sets Vary header with multiple header names" do 706 | conn = 707 | build_conn_for_path("/foo") 708 | |> Unpoly.put_vary(["X-Up-Target", "X-Up-Mode"]) 709 | 710 | assert ["X-Up-Target, X-Up-Mode"] = get_resp_header(conn, "vary") 711 | end 712 | 713 | test "accumulates headers when called multiple times" do 714 | conn = 715 | build_conn_for_path("/foo") 716 | |> Unpoly.put_vary("X-Up-Target") 717 | |> Unpoly.put_vary("X-Up-Mode") 718 | 719 | header = get_resp_header(conn, "vary") |> List.first() 720 | assert header =~ "X-Up-Target" 721 | assert header =~ "X-Up-Mode" 722 | end 723 | 724 | test "removes duplicate headers" do 725 | conn = 726 | build_conn_for_path("/foo") 727 | |> Unpoly.put_vary(["X-Up-Target", "X-Up-Mode"]) 728 | |> Unpoly.put_vary(["X-Up-Target", "X-Up-Context"]) 729 | 730 | header = get_resp_header(conn, "vary") |> List.first() 731 | # X-Up-Target should appear only once 732 | assert header == "X-Up-Target, X-Up-Mode, X-Up-Context" 733 | end 734 | end 735 | 736 | describe "not_modified/1" do 737 | test "sets 304 status code" do 738 | conn = 739 | build_conn_for_path("/foo") 740 | |> Unpoly.not_modified() 741 | 742 | assert conn.status == 304 743 | end 744 | 745 | test "sets X-Up-Target to :none" do 746 | conn = 747 | build_conn_for_path("/foo") 748 | |> Unpoly.not_modified() 749 | 750 | assert [":none"] = get_resp_header(conn, "x-up-target") 751 | end 752 | 753 | test "halts the connection" do 754 | conn = 755 | build_conn_for_path("/foo") 756 | |> Unpoly.not_modified() 757 | 758 | assert conn.halted == true 759 | end 760 | end 761 | 762 | describe "cache_fresh?/2" do 763 | test "returns true when ETag matches" do 764 | result = 765 | conn(:get, "/foo") 766 | |> put_req_header("if-none-match", "\"abc123\"") 767 | |> Unpoly.cache_fresh?(etag: "\"abc123\"") 768 | 769 | assert result == true 770 | end 771 | 772 | test "returns false when ETag does not match" do 773 | result = 774 | conn(:get, "/foo") 775 | |> put_req_header("if-none-match", "\"abc123\"") 776 | |> Unpoly.cache_fresh?(etag: "\"xyz789\"") 777 | 778 | assert result == false 779 | end 780 | 781 | test "returns true when Last-Modified is not newer than If-Modified-Since" do 782 | last_modified = ~U[2024-01-15 10:00:00Z] 783 | 784 | result = 785 | conn(:get, "/foo") 786 | |> put_req_header("if-modified-since", "Mon, 15 Jan 2024 10:30:00 GMT") 787 | |> Unpoly.cache_fresh?(last_modified: last_modified) 788 | 789 | assert result == true 790 | end 791 | 792 | test "returns false when Last-Modified is newer than If-Modified-Since" do 793 | last_modified = ~U[2024-01-15 11:00:00Z] 794 | 795 | result = 796 | conn(:get, "/foo") 797 | |> put_req_header("if-modified-since", "Mon, 15 Jan 2024 10:30:00 GMT") 798 | |> Unpoly.cache_fresh?(last_modified: last_modified) 799 | 800 | assert result == false 801 | end 802 | 803 | test "returns false when no conditional headers present" do 804 | result = 805 | conn(:get, "/foo") 806 | |> Unpoly.cache_fresh?(etag: "\"abc123\"") 807 | 808 | assert result == false 809 | end 810 | 811 | test "returns false when no options provided" do 812 | result = 813 | conn(:get, "/foo") 814 | |> put_req_header("if-none-match", "\"abc123\"") 815 | |> Unpoly.cache_fresh?([]) 816 | 817 | assert result == false 818 | end 819 | 820 | test "returns true if either ETag or Last-Modified matches" do 821 | # ETag matches but Last-Modified doesn't 822 | result = 823 | conn(:get, "/foo") 824 | |> put_req_header("if-none-match", "\"abc123\"") 825 | |> put_req_header("if-modified-since", "Mon, 15 Jan 2024 10:00:00 GMT") 826 | |> Unpoly.cache_fresh?(etag: "\"abc123\"", last_modified: ~U[2024-01-15 11:00:00Z]) 827 | 828 | assert result == true 829 | end 830 | end 831 | 832 | describe "accept_layer/2" do 833 | test "accepts layer without value" do 834 | conn = 835 | build_conn_for_path("/foo") 836 | |> Unpoly.accept_layer() 837 | 838 | assert ["null"] = get_resp_header(conn, "x-up-accept-layer") 839 | assert [":none"] = get_resp_header(conn, "x-up-target") 840 | end 841 | 842 | test "accepts layer with string value" do 843 | conn = 844 | build_conn_for_path("/foo") 845 | |> Unpoly.accept_layer("User was created") 846 | 847 | assert ["User was created"] = get_resp_header(conn, "x-up-accept-layer") 848 | assert [":none"] = get_resp_header(conn, "x-up-target") 849 | end 850 | 851 | test "accepts layer with map value" do 852 | conn = 853 | build_conn_for_path("/foo") 854 | |> Unpoly.accept_layer(%{id: 123, name: "Alice"}) 855 | 856 | header = get_resp_header(conn, "x-up-accept-layer") |> List.first() 857 | decoded = Jason.decode!(header) 858 | assert %{"id" => 123, "name" => "Alice"} = decoded 859 | assert [":none"] = get_resp_header(conn, "x-up-target") 860 | end 861 | end 862 | 863 | describe "dismiss_layer/2" do 864 | test "dismisses layer without value" do 865 | conn = 866 | build_conn_for_path("/foo") 867 | |> Unpoly.dismiss_layer() 868 | 869 | assert ["null"] = get_resp_header(conn, "x-up-dismiss-layer") 870 | assert [":none"] = get_resp_header(conn, "x-up-target") 871 | end 872 | 873 | test "dismisses layer with string value" do 874 | conn = 875 | build_conn_for_path("/foo") 876 | |> Unpoly.dismiss_layer("Operation cancelled") 877 | 878 | assert ["Operation cancelled"] = get_resp_header(conn, "x-up-dismiss-layer") 879 | assert [":none"] = get_resp_header(conn, "x-up-target") 880 | end 881 | 882 | test "dismisses layer with map value" do 883 | conn = 884 | build_conn_for_path("/foo") 885 | |> Unpoly.dismiss_layer(%{reason: "user_cancelled"}) 886 | 887 | header = get_resp_header(conn, "x-up-dismiss-layer") |> List.first() 888 | decoded = Jason.decode!(header) 889 | assert %{"reason" => "user_cancelled"} = decoded 890 | assert [":none"] = get_resp_header(conn, "x-up-target") 891 | end 892 | end 893 | 894 | def build_conn_for_path(path, method \\ :get) do 895 | conn(method, path) 896 | |> fetch_query_params() 897 | |> put_private(:phoenix_endpoint, __MODULE__) 898 | |> put_private(:phoenix_router, __MODULE__) 899 | end 900 | end 901 | -------------------------------------------------------------------------------- /lib/unpoly.ex: -------------------------------------------------------------------------------- 1 | defmodule Unpoly do 2 | @moduledoc """ 3 | A Plug adapter and helpers for Unpoly, the unobtrusive JavaScript framework. 4 | 5 | This library provides server-side support for Unpoly 3.0+, enabling seamless 6 | fragment updates, layer management, and cache revalidation in Phoenix applications. 7 | 8 | ## Version Compatibility 9 | 10 | - **3.0+**: Full support for Unpoly 3.0+ protocol 11 | - **2.x**: Support for Unpoly 2.x protocol (maintained for backward compatibility) 12 | - Unpoly 2.x clients remain fully supported 13 | 14 | ## Key Features 15 | 16 | ### Fragment Updates 17 | Read request headers to optimize server responses: 18 | - `target/1` - Get the CSS selector being updated 19 | - `mode/1` - Get the current layer mode (root, modal, drawer, etc.) 20 | - `context/1` - Access layer context data 21 | 22 | ### Cache Revalidation (New in 3.0) 23 | Unpoly 3.0 introduces sophisticated cache revalidation using standard HTTP headers: 24 | - `if_modified_since/1` and `if_none_match/1` - Read conditional request headers 25 | - `put_last_modified/2` and `put_etag/2` - Set cache validation headers 26 | - `put_vary/2` - Specify which request headers influence the response 27 | - `cache_fresh?/2` - Check if cached content is still valid 28 | - `not_modified/1` - Return a 304 Not Modified response 29 | 30 | ### Layer Management 31 | Control overlay layers from the server: 32 | - `accept_layer/2` - Accept and close an overlay with a value 33 | - `dismiss_layer/2` - Dismiss and close an overlay with a value 34 | - `open_layer/2` - Force opening a new overlay layer 35 | 36 | ### Cache Control 37 | - `expire_cache/2` - Mark cache entries for revalidation 38 | - `evict_cache/2` - Remove cache entries completely 39 | - `keep_cache/1` - Prevent automatic cache expiration 40 | 41 | ## Migration from 2.x to 3.0 42 | 43 | ### Breaking Changes 44 | 1. **X-Up-Title now requires JSON encoding**: The `put_title/2` function now 45 | JSON-encodes the title value as required by Unpoly 3.0. 46 | 47 | 2. **Deprecated headers**: X-Up-Reload-From-Time is deprecated in favor of 48 | standard Last-Modified/If-Modified-Since headers. Use the new cache 49 | revalidation helpers instead. 50 | 51 | ### New Features 52 | - Full cache revalidation support with ETags and Last-Modified headers 53 | - New convenience helpers: `accept_layer/2` and `dismiss_layer/2` 54 | - Better cache partitioning with the `Vary` header 55 | 56 | ## Plug Options 57 | 58 | When using `Unpoly` as a Plug in your endpoint or router: 59 | 60 | * `:cookie_name` - the cookie name where the request method is echoed to. 61 | Defaults to `"_up_method"`. 62 | * `:cookie_opts` - additional options to pass to method cookie. 63 | See `Plug.Conn.put_resp_cookie/4` for all available options. 64 | 65 | ## Example Usage 66 | 67 | # In your controller 68 | def show(conn, %{"id" => id}) do 69 | post = Blog.get_post!(id) 70 | etag = "\"post-\#{post.id}-\#{post.updated_at}\"" 71 | 72 | # Check if the client's cache is still fresh 73 | if Unpoly.cache_fresh?(conn, etag: etag) do 74 | Unpoly.not_modified(conn) 75 | else 76 | conn 77 | |> Unpoly.put_etag(etag) 78 | |> Unpoly.put_vary(["X-Up-Target", "X-Up-Mode"]) 79 | |> render("show.html", post: post) 80 | end 81 | end 82 | 83 | # Accept an overlay layer from the server 84 | def create(conn, params) do 85 | case Blog.create_post(params) do 86 | {:ok, post} -> 87 | conn 88 | |> Unpoly.accept_layer(%{id: post.id}) 89 | |> put_status(201) 90 | |> json(%{success: true}) 91 | 92 | {:error, changeset} -> 93 | conn 94 | |> put_status(422) 95 | |> render("new.html", changeset: changeset) 96 | end 97 | end 98 | 99 | ## Learn More 100 | 101 | - Unpoly Documentation: https://unpoly.com 102 | - Unpoly 3.0 Changes: https://unpoly.com/changes/3.0.0 103 | - Unpoly Protocol: https://unpoly.com/up.protocol 104 | - Cache Revalidation: https://unpoly.com/caching 105 | """ 106 | 107 | @doc """ 108 | Alias for `Unpoly.unpoly?/1` 109 | """ 110 | @spec up?(Plug.Conn.t()) :: boolean() 111 | def up?(conn), do: unpoly?(conn) 112 | 113 | @doc """ 114 | Returns whether the current request is a [page fragment update](https://unpoly.com/up.replace) 115 | triggered by an Unpoly frontend. 116 | 117 | This will eventually just check for the `X-Up-Version header`. 118 | Just in case a user still has an older version of Unpoly running on the frontend, 119 | we also check for the X-Up-Target header. 120 | """ 121 | @spec unpoly?(Plug.Conn.t()) :: boolean() 122 | def unpoly?(conn), do: version(conn) !== nil || target(conn) !== nil 123 | 124 | @doc """ 125 | Returns the current Unpoly version. 126 | 127 | The version is guaranteed to be set for all Unpoly requests. 128 | """ 129 | @spec version(Plug.Conn.t()) :: String.t() | nil 130 | def version(conn), do: get_req_header(conn, "x-up-version") 131 | 132 | @doc """ 133 | Returns the mode of the targeted layer. 134 | 135 | Server-side code is free to render different HTML for different modes. 136 | For example, you might prefer to not render a site navigation for overlays. 137 | """ 138 | @doc since: "2.0.0" 139 | @spec mode(Plug.Conn.t()) :: String.t() | nil 140 | def mode(conn), do: get_req_header(conn, "x-up-mode") 141 | 142 | @doc """ 143 | Returns the mode of the layer targeted for a failed fragment update. 144 | 145 | A fragment update is considered failed if the server responds with 146 | a status code other than 2xx, but still renders HTML. 147 | 148 | Server-side code is free to render different HTML for different modes. 149 | For example, you might prefer to not render a site navigation for overlays. 150 | """ 151 | @doc since: "2.0.0" 152 | @spec fail_mode(Plug.Conn.t()) :: String.t() | nil 153 | def fail_mode(conn), do: get_req_header(conn, "x-up-fail-mode") 154 | 155 | @doc """ 156 | Returns the mode of the layer from which the fragment update originated. 157 | 158 | This is an experimental feature that can be used to determine the context 159 | from which a request was made. 160 | 161 | Returns `nil` if the header is not present. 162 | """ 163 | @doc since: "2.0.0" 164 | @spec origin_mode(Plug.Conn.t()) :: String.t() | nil 165 | def origin_mode(conn), do: get_req_header(conn, "x-up-origin-mode") 166 | 167 | @doc """ 168 | Returns the context of the layer targeted for a failed fragment update. 169 | 170 | This is an experimental feature for handling context in failed updates. 171 | 172 | Returns an empty map if no context is present. 173 | """ 174 | @doc since: "2.0.0" 175 | @spec fail_context(Plug.Conn.t()) :: map() 176 | def fail_context(conn) do 177 | case get_req_header(conn, "x-up-fail-context") do 178 | nil -> %{} 179 | json -> Phoenix.json_library().decode!(json) 180 | end 181 | end 182 | 183 | @doc """ 184 | Returns the CSS selector for a fragment that Unpoly will update in 185 | case of a successful response (200 status code). 186 | 187 | The Unpoly frontend will expect an HTML response containing an element 188 | that matches this selector. 189 | 190 | Server-side code is free to optimize its successful response by only returning HTML 191 | that matches this selector. 192 | """ 193 | @spec target(Plug.Conn.t()) :: String.t() | nil 194 | def target(conn), do: get_req_header(conn, "x-up-target") 195 | 196 | @doc """ 197 | Returns the CSS selector for a fragment that Unpoly will update in 198 | case of an failed response. Server errors or validation failures are 199 | all examples for a failed response (non-200 status code). 200 | 201 | The Unpoly frontend will expect an HTML response containing an element 202 | that matches this selector. 203 | 204 | Server-side code is free to optimize its response by only returning HTML 205 | that matches this selector. 206 | """ 207 | @spec fail_target(Plug.Conn.t()) :: String.t() | nil 208 | def fail_target(conn), do: get_req_header(conn, "x-up-fail-target") 209 | 210 | @doc """ 211 | Returns the context of the targeted layer as a map. 212 | 213 | The context is sent by Unpoly in the X-Up-Context request header. 214 | It contains data about the layer's state (e.g., game state, wizard step, etc.). 215 | 216 | Returns an empty map if no context is present. 217 | 218 | ## Examples 219 | 220 | context(conn) 221 | # => %{"lives" => 3, "level" => 2} 222 | """ 223 | @doc since: "2.0.0" 224 | @spec context(Plug.Conn.t()) :: map() 225 | def context(conn) do 226 | case get_req_header(conn, "x-up-context") do 227 | nil -> %{} 228 | json -> Phoenix.json_library().decode!(json) 229 | end 230 | end 231 | 232 | @doc """ 233 | Returns whether the current layer has context. 234 | 235 | Returns `true` if the X-Up-Context request header is present and contains 236 | context data, `false` otherwise. 237 | 238 | ## Examples 239 | 240 | context?(conn) 241 | # => true (if context is present) 242 | """ 243 | @doc since: "2.0.0" 244 | @spec context?(Plug.Conn.t()) :: boolean() 245 | def context?(conn) do 246 | get_req_header(conn, "x-up-context") != nil 247 | end 248 | 249 | @doc """ 250 | Returns whether the given CSS selector is targeted by the current fragment 251 | update in case of a successful response (200 status code). 252 | 253 | Note that the matching logic is very simplistic and does not actually know 254 | how your page layout is structured. It will return `true` if 255 | the tested selector and the requested CSS selector matches exactly, or if the 256 | requested selector is `body` or `html`. 257 | 258 | Always returns `true` if the current request is not an Unpoly fragment update. 259 | """ 260 | @spec target?(Plug.Conn.t(), String.t()) :: boolean() 261 | def target?(conn, tested_target), do: query_target(conn, target(conn), tested_target) 262 | 263 | @doc """ 264 | Returns whether the given CSS selector is targeted by the current fragment 265 | update in case of a failed response (non-200 status code). 266 | 267 | Note that the matching logic is very simplistic and does not actually know 268 | how your page layout is structured. It will return `true` if 269 | the tested selector and the requested CSS selector matches exactly, or if the 270 | requested selector is `body` or `html`. 271 | 272 | Always returns `true` if the current request is not an Unpoly fragment update. 273 | """ 274 | @spec fail_target?(Plug.Conn.t(), String.t()) :: boolean() 275 | def fail_target?(conn, tested_target), do: query_target(conn, fail_target(conn), tested_target) 276 | 277 | @doc """ 278 | Returns whether the given CSS selector is targeted by the current fragment 279 | update for either a success or a failed response. 280 | 281 | Note that the matching logic is very simplistic and does not actually know 282 | how your page layout is structured. It will return `true` if 283 | the tested selector and the requested CSS selector matches exactly, or if the 284 | requested selector is `body` or `html`. 285 | 286 | Always returns `true` if the current request is not an Unpoly fragment update. 287 | """ 288 | @spec any_target?(Plug.Conn.t(), String.t()) :: boolean() 289 | def any_target?(conn, tested_target), 290 | do: target?(conn, tested_target) || fail_target?(conn, tested_target) 291 | 292 | @doc """ 293 | Returns whether the current form submission should be 294 | [validated](https://unpoly.com/input-up-validate) (and not be saved to the database). 295 | """ 296 | @spec validate?(Plug.Conn.t()) :: boolean() 297 | def validate?(conn), do: validate_name(conn) !== nil 298 | 299 | @doc """ 300 | Returns whether the current layer is the root layer. 301 | 302 | The root layer is the default layer that contains the initial page content. 303 | It is identified by the mode "root". 304 | 305 | Returns `true` if the current layer is the root layer, or if the request 306 | is not an Unpoly request (full page load). 307 | 308 | ## Examples 309 | 310 | root?(conn) 311 | # => true 312 | """ 313 | @doc since: "2.0.0" 314 | @spec root?(Plug.Conn.t()) :: boolean() 315 | def root?(conn) do 316 | case mode(conn) do 317 | nil -> true 318 | "root" -> true 319 | _ -> false 320 | end 321 | end 322 | 323 | @doc """ 324 | Returns whether the current layer is an overlay. 325 | 326 | Overlays are layers that are stacked on top of the root layer, 327 | such as modal dialogs, popups, drawers, or covers. 328 | 329 | Returns `false` if the current layer is the root layer, or if the request 330 | is not an Unpoly request (full page load). 331 | 332 | ## Examples 333 | 334 | overlay?(conn) 335 | # => true (for modes like "modal", "popup", "drawer", "cover") 336 | """ 337 | @doc since: "2.0.0" 338 | @spec overlay?(Plug.Conn.t()) :: boolean() 339 | def overlay?(conn) do 340 | case mode(conn) do 341 | nil -> false 342 | "root" -> false 343 | _ -> true 344 | end 345 | end 346 | 347 | @doc """ 348 | If the current form submission is a [validation](https://unpoly.com/input-up-validate), 349 | this returns the name attribute of the form field that has triggered 350 | the validation. 351 | """ 352 | @spec validate_name(Plug.Conn.t()) :: String.t() | nil 353 | def validate_name(conn), do: get_req_header(conn, "x-up-validate") 354 | 355 | @doc """ 356 | Returns the timestamp from the `If-Modified-Since` request header. 357 | 358 | This is part of Unpoly's cache revalidation system. When a cached fragment 359 | has a `Last-Modified` timestamp, Unpoly will send it back in the 360 | `If-Modified-Since` header when revalidating the cache. 361 | 362 | If the fragment hasn't changed since this timestamp, the server can return 363 | a 304 Not Modified response using `not_modified/1`. 364 | 365 | Returns `nil` if the header is not present or cannot be parsed. 366 | 367 | ## Examples 368 | 369 | if_modified_since(conn) 370 | # => ~U[2024-01-15 10:30:00Z] 371 | 372 | """ 373 | @doc since: "3.0.0" 374 | @spec if_modified_since(Plug.Conn.t()) :: DateTime.t() | nil 375 | def if_modified_since(conn) do 376 | case get_req_header(conn, "if-modified-since") do 377 | nil -> nil 378 | date_string -> parse_http_date(date_string) 379 | end 380 | end 381 | 382 | @doc """ 383 | Returns the ETag from the `If-None-Match` request header. 384 | 385 | This is part of Unpoly's cache revalidation system. When a cached fragment 386 | has an ETag, Unpoly will send it back in the `If-None-Match` header when 387 | revalidating the cache. 388 | 389 | If the current ETag matches this value, the server can return a 304 Not 390 | Modified response using `not_modified/1`. 391 | 392 | Returns `nil` if the header is not present. 393 | 394 | ## Examples 395 | 396 | if_none_match(conn) 397 | # => "\"abc123\"" 398 | 399 | """ 400 | @doc since: "3.0.0" 401 | @spec if_none_match(Plug.Conn.t()) :: String.t() | nil 402 | def if_none_match(conn), do: get_req_header(conn, "if-none-match") 403 | 404 | @doc """ 405 | Returns the timestamp of an existing fragment that is being reloaded. 406 | 407 | The timestamp must be explicitely set by the user as an [up-time] attribute on the fragment. 408 | It should indicate the time when the fragment's underlying data was last changed. 409 | 410 | ## Deprecation Notice 411 | 412 | This header (X-Up-Reload-From-Time) is deprecated in Unpoly 3.0. 413 | Instead, use standard HTTP conditional request headers: 414 | - Use `put_last_modified/2` to set the `Last-Modified` response header 415 | - Use `if_modified_since/1` to check the `If-Modified-Since` request header 416 | - Use `not_modified/1` to return a 304 Not Modified response 417 | 418 | This function is kept for backward compatibility with Unpoly 2.x clients. 419 | """ 420 | @doc since: "2.0.0" 421 | @doc deprecated: "Use standard Last-Modified/If-Modified-Since headers instead" 422 | @spec reload_from_time(Plug.Conn.t()) :: String.t() | nil 423 | def reload_from_time(conn) do 424 | with timestamp when is_binary(timestamp) <- get_req_header(conn, "x-up-reload-from-time"), 425 | {timestamp, ""} <- Integer.parse(timestamp), 426 | {:ok, datetime} <- DateTime.from_unix(timestamp) do 427 | datetime 428 | else 429 | _ -> nil 430 | end 431 | end 432 | 433 | @doc """ 434 | Returns whether the current request is reloading an existing fragment. 435 | 436 | ## Deprecation Notice 437 | 438 | This function relies on the deprecated X-Up-Reload-From-Time header. 439 | In Unpoly 3.0, use standard HTTP conditional request helpers instead: 440 | - Check `if_modified_since/1` for conditional requests 441 | - Use `cache_fresh?/2` to determine if cached content is still valid 442 | 443 | This function is kept for backward compatibility with Unpoly 2.x clients. 444 | """ 445 | @doc since: "2.0.0" 446 | @doc deprecated: "Use if_modified_since/1 or cache_fresh?/2 instead" 447 | @spec reload?(Plug.Conn.t()) :: boolean() 448 | def reload?(conn), do: reload_from_time(conn) !== nil 449 | 450 | @doc """ 451 | Forces Unpoly to use the given string as the document title when processing 452 | this response. 453 | 454 | This is useful when you skip rendering the `` in an Unpoly request. 455 | 456 | Note: In Unpoly 3.0+, the title value is JSON-encoded as required by the protocol. 457 | """ 458 | @spec put_title(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 459 | def put_title(conn, new_title) do 460 | encoded_title = 461 | new_title 462 | |> Phoenix.json_library().encode_to_iodata!() 463 | |> to_string() 464 | 465 | Plug.Conn.put_resp_header(conn, "x-up-title", encoded_title) 466 | end 467 | 468 | @doc """ 469 | Expires cache entries matching the given URL pattern. 470 | 471 | Expired cache entries will be revalidated when accessed. 472 | Use "*" to expire all cache entries. 473 | Use "false" to prevent automatic cache expiration after non-GET requests. 474 | 475 | ## Examples 476 | 477 | Unpoly.expire_cache(conn, "/notes/*") 478 | Unpoly.expire_cache(conn, "*") 479 | Unpoly.expire_cache(conn, "false") 480 | """ 481 | @doc since: "2.0.0" 482 | @spec expire_cache(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 483 | def expire_cache(conn, pattern) do 484 | put_resp_expire_cache_header(conn, pattern) 485 | end 486 | 487 | @doc """ 488 | Evicts (removes) cache entries matching the given URL pattern. 489 | 490 | Evicted cache entries are completely removed from the cache. 491 | Use "*" to evict all cache entries. 492 | 493 | ## Examples 494 | 495 | Unpoly.evict_cache(conn, "/notes/*") 496 | Unpoly.evict_cache(conn, "*") 497 | """ 498 | @doc since: "2.0.0" 499 | @spec evict_cache(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 500 | def evict_cache(conn, pattern) do 501 | put_resp_evict_cache_header(conn, pattern) 502 | end 503 | 504 | @doc """ 505 | Prevents automatic cache expiration after this non-GET request. 506 | 507 | By default, Unpoly expires the entire cache after non-GET requests. 508 | This helper prevents that behavior. 509 | 510 | ## Examples 511 | 512 | Unpoly.keep_cache(conn) 513 | """ 514 | @doc since: "2.0.0" 515 | @spec keep_cache(Plug.Conn.t()) :: Plug.Conn.t() 516 | def keep_cache(conn) do 517 | put_resp_expire_cache_header(conn, "false") 518 | end 519 | 520 | @doc """ 521 | Updates the layer context in the response. 522 | 523 | The context will be merged with the existing layer context on the client. 524 | To remove a context key, set its value to nil. 525 | 526 | ## Examples 527 | 528 | Unpoly.put_context(conn, %{lives: 2}) 529 | Unpoly.put_context(conn, %{removed_key: nil}) 530 | """ 531 | @doc since: "2.0.0" 532 | @spec put_context(Plug.Conn.t(), map()) :: Plug.Conn.t() 533 | def put_context(conn, context) when is_map(context) do 534 | put_resp_context_header(conn, context) 535 | end 536 | 537 | @doc """ 538 | Forces the response to open in a new overlay layer with the given options. 539 | 540 | This is useful for server-side code that wants to force a response to open 541 | in an overlay, even when the request was not made from an overlay. 542 | 543 | ## Examples 544 | 545 | Unpoly.open_layer(conn, %{mode: "modal"}) 546 | Unpoly.open_layer(conn, %{mode: "drawer", size: "large"}) 547 | """ 548 | @doc since: "2.0.0" 549 | @spec open_layer(Plug.Conn.t(), map()) :: Plug.Conn.t() 550 | def open_layer(conn, options) when is_map(options) do 551 | put_resp_open_layer_header(conn, options) 552 | end 553 | 554 | @doc """ 555 | Emits one or more JavaScript events on the frontend. 556 | 557 | Events are sent via the X-Up-Events response header and will be 558 | triggered on the document when the response is received. 559 | 560 | You can pass either a single event type (string) or a map of events 561 | with their properties. 562 | 563 | ## Examples 564 | 565 | # Emit a simple event without properties 566 | Unpoly.emit_events(conn, "user:created") 567 | 568 | # Emit an event with properties 569 | Unpoly.emit_events(conn, %{"user:created" => %{id: 123, name: "Alice"}}) 570 | 571 | # Emit multiple events 572 | Unpoly.emit_events(conn, %{ 573 | "user:created" => %{id: 123}, 574 | "notification:show" => %{message: "User created"} 575 | }) 576 | """ 577 | @doc since: "2.0.0" 578 | @spec emit_events(Plug.Conn.t(), String.t() | map()) :: Plug.Conn.t() 579 | def emit_events(conn, event_type) when is_binary(event_type) do 580 | emit_events(conn, %{event_type => %{}}) 581 | end 582 | 583 | def emit_events(conn, events) when is_map(events) do 584 | put_resp_events_header(conn, events) 585 | end 586 | 587 | @doc """ 588 | Accepts the current overlay layer and closes it, optionally passing a value to the parent layer. 589 | 590 | This is a high-level convenience helper that combines setting the X-Up-Accept-Layer 591 | header and preventing rendering of HTML by setting X-Up-Target to ":none". 592 | 593 | When called without a value (or with `nil`), it simply accepts the overlay. 594 | When called with a value (string or map), that value is passed to the parent layer. 595 | 596 | ## Examples 597 | 598 | # Accept overlay without a value 599 | Unpoly.accept_layer(conn) 600 | 601 | # Accept overlay with a string value 602 | Unpoly.accept_layer(conn, "User was created") 603 | 604 | # Accept overlay with a structured value 605 | Unpoly.accept_layer(conn, %{id: 123, name: "Alice"}) 606 | 607 | """ 608 | @doc since: "3.0.0" 609 | @spec accept_layer(Plug.Conn.t(), term()) :: Plug.Conn.t() 610 | def accept_layer(conn, value \\ nil) do 611 | conn 612 | |> put_resp_accept_layer_header(value) 613 | |> put_resp_target_header(":none") 614 | end 615 | 616 | @doc """ 617 | Dismisses the current overlay layer and closes it, optionally passing a value to the parent layer. 618 | 619 | This is a high-level convenience helper that combines setting the X-Up-Dismiss-Layer 620 | header and preventing rendering of HTML by setting X-Up-Target to ":none". 621 | 622 | When called without a value (or with `nil`), it simply dismisses the overlay. 623 | When called with a value (string or map), that value is passed to the parent layer. 624 | 625 | ## Examples 626 | 627 | # Dismiss overlay without a value 628 | Unpoly.dismiss_layer(conn) 629 | 630 | # Dismiss overlay with a string value 631 | Unpoly.dismiss_layer(conn, "Operation cancelled") 632 | 633 | # Dismiss overlay with a structured value 634 | Unpoly.dismiss_layer(conn, %{reason: "user_cancelled"}) 635 | 636 | """ 637 | @doc since: "3.0.0" 638 | @spec dismiss_layer(Plug.Conn.t(), term()) :: Plug.Conn.t() 639 | def dismiss_layer(conn, value \\ nil) do 640 | conn 641 | |> put_resp_dismiss_layer_header(value) 642 | |> put_resp_target_header(":none") 643 | end 644 | 645 | @doc """ 646 | Sets the `ETag` response header for cache validation. 647 | 648 | ETags are identifiers for specific versions of a resource. When a client 649 | caches a fragment with an ETag, it will send it back in the `If-None-Match` 650 | header when revalidating the cache. 651 | 652 | If the ETag matches, the server can return a 304 Not Modified response 653 | using `not_modified/1`. 654 | 655 | ## Examples 656 | 657 | Unpoly.put_etag(conn, "\"abc123\"") 658 | Unpoly.put_etag(conn, "\"v2-" <> calculate_hash(content) <> "\"") 659 | 660 | """ 661 | @doc since: "3.0.0" 662 | @spec put_etag(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 663 | def put_etag(conn, etag) do 664 | Plug.Conn.put_resp_header(conn, "etag", etag) 665 | end 666 | 667 | @doc """ 668 | Sets the `Last-Modified` response header for cache validation. 669 | 670 | This header indicates when the resource was last changed. When a client 671 | caches a fragment with a Last-Modified timestamp, it will send it back in 672 | the `If-Modified-Since` header when revalidating the cache. 673 | 674 | If the resource hasn't changed since that time, the server can return a 675 | 304 Not Modified response using `not_modified/1`. 676 | 677 | ## Examples 678 | 679 | Unpoly.put_last_modified(conn, ~U[2024-01-15 10:30:00Z]) 680 | Unpoly.put_last_modified(conn, user.updated_at) 681 | 682 | """ 683 | @doc since: "3.0.0" 684 | @spec put_last_modified(Plug.Conn.t(), DateTime.t()) :: Plug.Conn.t() 685 | def put_last_modified(conn, datetime) do 686 | http_date = format_http_date(datetime) 687 | Plug.Conn.put_resp_header(conn, "last-modified", http_date) 688 | end 689 | 690 | @doc """ 691 | Sets the `Vary` response header for cache partitioning. 692 | 693 | The Vary header tells the cache which request headers influenced the response. 694 | This is critical for proper cache partitioning in Unpoly 3.0. 695 | 696 | You can pass either a single header name (string) or a list of header names. 697 | If called multiple times, headers will be accumulated. 698 | 699 | ## Examples 700 | 701 | # Single header 702 | Unpoly.put_vary(conn, "X-Up-Target") 703 | 704 | # Multiple headers 705 | Unpoly.put_vary(conn, ["X-Up-Target", "X-Up-Mode"]) 706 | 707 | # Common Unpoly headers 708 | Unpoly.put_vary(conn, ["X-Up-Target", "X-Up-Mode", "X-Up-Context"]) 709 | 710 | """ 711 | @doc since: "3.0.0" 712 | @spec put_vary(Plug.Conn.t(), String.t() | list(String.t())) :: Plug.Conn.t() 713 | def put_vary(conn, header) when is_binary(header) do 714 | put_vary(conn, [header]) 715 | end 716 | 717 | def put_vary(conn, headers) when is_list(headers) do 718 | # Get existing Vary header if present 719 | existing = Plug.Conn.get_resp_header(conn, "vary") 720 | 721 | # Combine with new headers 722 | all_headers = 723 | case existing do 724 | [] -> headers 725 | [existing_value] -> String.split(existing_value, ", ") ++ headers 726 | end 727 | 728 | # Remove duplicates and join 729 | vary_value = 730 | all_headers 731 | |> Enum.uniq() 732 | |> Enum.join(", ") 733 | 734 | Plug.Conn.put_resp_header(conn, "vary", vary_value) 735 | end 736 | 737 | @doc """ 738 | Returns a 304 Not Modified response for cache revalidation. 739 | 740 | This helper sets the response status to 304, sets X-Up-Target to ":none" 741 | (telling Unpoly not to expect any HTML), and halts the connection. 742 | 743 | Use this after checking conditional request headers like `If-None-Match` 744 | or `If-Modified-Since` when the cached content is still valid. 745 | 746 | ## Examples 747 | 748 | def show(conn, %{"id" => id}) do 749 | post = Posts.get_post!(id) 750 | etag = "\"post-\#{post.id}-\#{post.updated_at}\"" 751 | 752 | if if_none_match(conn) == etag do 753 | not_modified(conn) 754 | else 755 | conn 756 | |> put_etag(etag) 757 | |> render("show.html", post: post) 758 | end 759 | end 760 | 761 | """ 762 | @doc since: "3.0.0" 763 | @spec not_modified(Plug.Conn.t()) :: Plug.Conn.t() 764 | def not_modified(conn) do 765 | conn 766 | |> Plug.Conn.put_status(304) 767 | |> put_resp_target_header(":none") 768 | |> Plug.Conn.halt() 769 | end 770 | 771 | @doc """ 772 | Checks if the cached content is still fresh based on conditional request headers. 773 | 774 | This helper simplifies cache revalidation logic by checking both ETag and 775 | Last-Modified conditions in one call. 776 | 777 | Returns `true` if either: 778 | - The `If-None-Match` header matches the provided `:etag` 779 | - The `If-Modified-Since` header is >= the provided `:last_modified` 780 | 781 | ## Examples 782 | 783 | def show(conn, %{"id" => id}) do 784 | post = Posts.get_post!(id) 785 | 786 | if cache_fresh?(conn, last_modified: post.updated_at) do 787 | not_modified(conn) 788 | else 789 | conn 790 | |> put_last_modified(post.updated_at) 791 | |> render("show.html", post: post) 792 | end 793 | end 794 | 795 | # With ETag 796 | etag = calculate_etag(content) 797 | if cache_fresh?(conn, etag: etag) do 798 | not_modified(conn) 799 | else 800 | conn 801 | |> put_etag(etag) 802 | |> render(content) 803 | end 804 | 805 | """ 806 | @doc since: "3.0.0" 807 | @spec cache_fresh?(Plug.Conn.t(), keyword()) :: boolean() 808 | def cache_fresh?(conn, opts) do 809 | etag_matches = 810 | case Keyword.get(opts, :etag) do 811 | nil -> false 812 | etag -> if_none_match(conn) == etag 813 | end 814 | 815 | last_modified_fresh = 816 | case Keyword.get(opts, :last_modified) do 817 | nil -> 818 | false 819 | 820 | last_modified -> 821 | case if_modified_since(conn) do 822 | nil -> false 823 | since -> DateTime.compare(last_modified, since) != :gt 824 | end 825 | end 826 | 827 | etag_matches || last_modified_fresh 828 | end 829 | 830 | # Plug 831 | 832 | def init(opts \\ []) do 833 | cookie_name = Keyword.get(opts, :cookie_name, "_up_method") 834 | cookie_opts = Keyword.get(opts, :cookie_opts, http_only: false) 835 | {cookie_name, cookie_opts} 836 | end 837 | 838 | def call(conn, {cookie_name, cookie_opts}) do 839 | conn 840 | |> Plug.Conn.fetch_cookies() 841 | |> append_method_cookie(cookie_name, cookie_opts) 842 | end 843 | 844 | @doc """ 845 | Sets the value of the "X-Up-Accept-Layer" response header. 846 | """ 847 | @doc since: "2.0.0" 848 | @spec put_resp_accept_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() 849 | def put_resp_accept_layer_header(conn, value) when is_binary(value) do 850 | Plug.Conn.put_resp_header(conn, "x-up-accept-layer", value) 851 | end 852 | 853 | def put_resp_accept_layer_header(conn, value) do 854 | value = Phoenix.json_library().encode_to_iodata!(value) 855 | put_resp_accept_layer_header(conn, to_string(value)) 856 | end 857 | 858 | @doc """ 859 | Sets the value of the "X-Up-Dismiss-Layer" response header. 860 | """ 861 | @doc since: "2.0.0" 862 | @spec put_resp_dismiss_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() 863 | def put_resp_dismiss_layer_header(conn, value) when is_binary(value) do 864 | Plug.Conn.put_resp_header(conn, "x-up-dismiss-layer", value) 865 | end 866 | 867 | def put_resp_dismiss_layer_header(conn, value) do 868 | value = Phoenix.json_library().encode_to_iodata!(value) 869 | put_resp_dismiss_layer_header(conn, to_string(value)) 870 | end 871 | 872 | @doc """ 873 | Sets the value of the "X-Up-Events" response header. 874 | """ 875 | @doc since: "2.0.0" 876 | @spec put_resp_events_header(Plug.Conn.t(), term) :: Plug.Conn.t() 877 | def put_resp_events_header(conn, value) when is_binary(value) do 878 | Plug.Conn.put_resp_header(conn, "x-up-events", value) 879 | end 880 | 881 | def put_resp_events_header(conn, value) do 882 | value = Phoenix.json_library().encode_to_iodata!(value) 883 | put_resp_events_header(conn, to_string(value)) 884 | end 885 | 886 | @doc """ 887 | Sets the value of the "X-Up-Location" response header. 888 | """ 889 | @spec put_resp_location_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 890 | def put_resp_location_header(conn, value) do 891 | Plug.Conn.put_resp_header(conn, "x-up-location", value) 892 | end 893 | 894 | @doc """ 895 | Sets the value of the "X-Up-Method" response header. 896 | """ 897 | @spec put_resp_method_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 898 | def put_resp_method_header(conn, value) do 899 | Plug.Conn.put_resp_header(conn, "x-up-method", value) 900 | end 901 | 902 | @doc """ 903 | Sets the value of the "X-Up-Target" response header. 904 | """ 905 | @spec put_resp_target_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 906 | def put_resp_target_header(conn, value) do 907 | Plug.Conn.put_resp_header(conn, "x-up-target", value) 908 | end 909 | 910 | @doc """ 911 | Sets the value of the "X-Up-Evict-Cache" response header. 912 | 913 | The client will evict cached responses that match the given URL pattern. 914 | Use "*" to evict all cached entries. 915 | 916 | ## Examples 917 | 918 | Unpoly.put_resp_evict_cache_header(conn, "/notes/*") 919 | Unpoly.put_resp_evict_cache_header(conn, "*") 920 | """ 921 | @doc since: "2.0.0" 922 | @spec put_resp_evict_cache_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 923 | def put_resp_evict_cache_header(conn, value) do 924 | Plug.Conn.put_resp_header(conn, "x-up-evict-cache", value) 925 | end 926 | 927 | @doc """ 928 | Sets the value of the "X-Up-Expire-Cache" response header. 929 | 930 | The client will expire cached responses that match the given URL pattern, 931 | forcing revalidation on next access. 932 | Use "*" to expire all cached entries. 933 | Use "false" to prevent automatic cache expiration after non-GET requests. 934 | 935 | ## Examples 936 | 937 | Unpoly.put_resp_expire_cache_header(conn, "/notes/*") 938 | Unpoly.put_resp_expire_cache_header(conn, "*") 939 | Unpoly.put_resp_expire_cache_header(conn, "false") 940 | """ 941 | @doc since: "2.0.0" 942 | @spec put_resp_expire_cache_header(Plug.Conn.t(), String.t()) :: Plug.Conn.t() 943 | def put_resp_expire_cache_header(conn, value) do 944 | Plug.Conn.put_resp_header(conn, "x-up-expire-cache", value) 945 | end 946 | 947 | @doc """ 948 | Sets the value of the "X-Up-Context" response header. 949 | 950 | The client will update the layer context with the given value. 951 | Use this to modify the layer's context from the server side. 952 | 953 | ## Examples 954 | 955 | Unpoly.put_resp_context_header(conn, %{lives: 2}) 956 | """ 957 | @doc since: "2.0.0" 958 | @spec put_resp_context_header(Plug.Conn.t(), term) :: Plug.Conn.t() 959 | def put_resp_context_header(conn, value) when is_binary(value) do 960 | Plug.Conn.put_resp_header(conn, "x-up-context", value) 961 | end 962 | 963 | def put_resp_context_header(conn, value) do 964 | value = Phoenix.json_library().encode_to_iodata!(value) 965 | put_resp_context_header(conn, to_string(value)) 966 | end 967 | 968 | @doc """ 969 | Sets the value of the "X-Up-Open-Layer" response header. 970 | 971 | The client will open a new overlay layer with the given options. 972 | This is useful for forcing a response to open in an overlay. 973 | 974 | ## Examples 975 | 976 | Unpoly.put_resp_open_layer_header(conn, %{mode: "modal", size: "large"}) 977 | """ 978 | @doc since: "2.0.0" 979 | @spec put_resp_open_layer_header(Plug.Conn.t(), term) :: Plug.Conn.t() 980 | def put_resp_open_layer_header(conn, value) when is_binary(value) do 981 | Plug.Conn.put_resp_header(conn, "x-up-open-layer", value) 982 | end 983 | 984 | def put_resp_open_layer_header(conn, value) do 985 | value = Phoenix.json_library().encode_to_iodata!(value) 986 | put_resp_open_layer_header(conn, to_string(value)) 987 | end 988 | 989 | defp append_method_cookie(conn, cookie_name, cookie_opts) do 990 | cond do 991 | conn.method != "GET" && !up?(conn) -> 992 | Plug.Conn.put_resp_cookie(conn, cookie_name, conn.method, cookie_opts) 993 | 994 | Map.has_key?(conn.req_cookies, "_up_method") -> 995 | Plug.Conn.delete_resp_cookie(conn, cookie_name, cookie_opts) 996 | 997 | true -> 998 | conn 999 | end 1000 | end 1001 | 1002 | ## Helpers 1003 | 1004 | defp get_req_header(conn, key), 1005 | do: Plug.Conn.get_req_header(conn, key) |> List.first() 1006 | 1007 | defp query_target(conn, actual_target, tested_target) do 1008 | if up?(conn) do 1009 | cond do 1010 | actual_target == tested_target -> true 1011 | actual_target == "html" -> true 1012 | actual_target == "body" && tested_target not in ["head", "title", "meta"] -> true 1013 | true -> false 1014 | end 1015 | else 1016 | true 1017 | end 1018 | end 1019 | 1020 | # Parse HTTP date format (RFC 7231) 1021 | # Examples: "Mon, 15 Jan 2024 10:30:00 GMT" 1022 | defp parse_http_date(date_string) do 1023 | # HTTP dates are in IMF-fixdate format: "Day, DD Mon YYYY HH:MM:SS GMT" 1024 | # We'll use a simple regex parser since Elixir doesn't have built-in HTTP date parsing 1025 | case Regex.run( 1026 | ~r/^[A-Za-z]{3}, (\d{2}) ([A-Za-z]{3}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/, 1027 | date_string 1028 | ) do 1029 | [_, day, month_name, year, hour, minute, second] -> 1030 | month = month_name_to_number(month_name) 1031 | 1032 | if month do 1033 | {:ok, datetime} = 1034 | DateTime.new( 1035 | Date.new!(String.to_integer(year), month, String.to_integer(day)), 1036 | Time.new!( 1037 | String.to_integer(hour), 1038 | String.to_integer(minute), 1039 | String.to_integer(second) 1040 | ), 1041 | "Etc/UTC" 1042 | ) 1043 | 1044 | datetime 1045 | else 1046 | nil 1047 | end 1048 | 1049 | _ -> 1050 | nil 1051 | end 1052 | rescue 1053 | _ -> nil 1054 | end 1055 | 1056 | defp month_name_to_number("Jan"), do: 1 1057 | defp month_name_to_number("Feb"), do: 2 1058 | defp month_name_to_number("Mar"), do: 3 1059 | defp month_name_to_number("Apr"), do: 4 1060 | defp month_name_to_number("May"), do: 5 1061 | defp month_name_to_number("Jun"), do: 6 1062 | defp month_name_to_number("Jul"), do: 7 1063 | defp month_name_to_number("Aug"), do: 8 1064 | defp month_name_to_number("Sep"), do: 9 1065 | defp month_name_to_number("Oct"), do: 10 1066 | defp month_name_to_number("Nov"), do: 11 1067 | defp month_name_to_number("Dec"), do: 12 1068 | defp month_name_to_number(_), do: nil 1069 | 1070 | # Format DateTime as HTTP date (RFC 7231) 1071 | # Example: "Mon, 15 Jan 2024 10:30:00 GMT" 1072 | defp format_http_date(datetime) do 1073 | datetime = DateTime.shift_zone!(datetime, "Etc/UTC") 1074 | day_name = day_of_week_name(Date.day_of_week(datetime)) 1075 | month_name = number_to_month_name(datetime.month) 1076 | 1077 | "#{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" 1078 | end 1079 | 1080 | defp day_of_week_name(1), do: "Mon" 1081 | defp day_of_week_name(2), do: "Tue" 1082 | defp day_of_week_name(3), do: "Wed" 1083 | defp day_of_week_name(4), do: "Thu" 1084 | defp day_of_week_name(5), do: "Fri" 1085 | defp day_of_week_name(6), do: "Sat" 1086 | defp day_of_week_name(7), do: "Sun" 1087 | 1088 | defp number_to_month_name(1), do: "Jan" 1089 | defp number_to_month_name(2), do: "Feb" 1090 | defp number_to_month_name(3), do: "Mar" 1091 | defp number_to_month_name(4), do: "Apr" 1092 | defp number_to_month_name(5), do: "May" 1093 | defp number_to_month_name(6), do: "Jun" 1094 | defp number_to_month_name(7), do: "Jul" 1095 | defp number_to_month_name(8), do: "Aug" 1096 | defp number_to_month_name(9), do: "Sep" 1097 | defp number_to_month_name(10), do: "Oct" 1098 | defp number_to_month_name(11), do: "Nov" 1099 | defp number_to_month_name(12), do: "Dec" 1100 | end 1101 | --------------------------------------------------------------------------------