├── .circleci └── config.yml ├── .config └── rubocop │ └── config.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── issue.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .reek.yml ├── .ruby-version ├── CITATION.cff ├── Gemfile ├── LICENSE.adoc ├── README.adoc ├── Rakefile ├── bin ├── console ├── rake ├── rspec ├── rubocop └── setup ├── htmx.gemspec ├── lib ├── htmx.rb └── htmx │ ├── error.rb │ ├── headers │ ├── request.rb │ └── response.rb │ └── prefixer.rb └── spec ├── lib ├── htmx │ ├── headers │ │ ├── request_spec.rb │ │ └── response_spec.rb │ └── prefixer_spec.rb └── htmx_spec.rb ├── spec_helper.rb └── support └── shared_contexts └── temp_dir.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | build: 4 | working_directory: ~/project 5 | docker: 6 | - image: bkuhlmann/alpine-ruby:latest 7 | steps: 8 | - checkout 9 | 10 | - restore_cache: 11 | name: Gems Restore 12 | keys: 13 | - gem-cache-{{.Branch}}-{{checksum "Gemfile"}}-{{checksum "htmx.gemspec"}} 14 | - gem-cache- 15 | 16 | - run: 17 | name: Gems Install 18 | command: | 19 | gem update --system 20 | bundle config set path "vendor/bundle" 21 | bundle install 22 | 23 | - save_cache: 24 | name: Gems Store 25 | key: gem-cache-{{.Branch}}-{{checksum "Gemfile"}}-{{checksum "htmx.gemspec"}} 26 | paths: 27 | - vendor/bundle 28 | 29 | - run: 30 | name: Rake 31 | command: bundle exec rake 32 | 33 | - store_artifacts: 34 | name: SimpleCov Report 35 | path: ~/project/coverage 36 | destination: coverage 37 | -------------------------------------------------------------------------------- /.config/rubocop/config.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | caliber: config/all.yml 3 | 4 | Metrics/ParameterLists: 5 | Exclude: 6 | - "lib/htmx/headers/request.rb" 7 | - "lib/htmx/headers/response.rb" 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [bkuhlmann] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Community 4 | url: https://alchemists.io/community 5 | about: Please ask questions or discuss specifics here. 6 | - name: Security 7 | url: https://alchemists.io/policies/security 8 | about: Please report security vulnerabilities here. 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | title: "Add|Update|Fix|Remove|Refactor " 4 | about: Report an issue. Please use only one of the subject prefixes. 5 | --- 6 | 7 | 10 | 11 | ## Why 12 | 13 | 14 | ## How 15 | 16 | 17 | ## Notes 18 | 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Overview 2 | 3 | 4 | ## Screenshots/Screencasts 5 | 6 | 7 | ## Details 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg 5 | tmp 6 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | exclude_paths: 2 | - tmp 3 | - vendor 4 | 5 | detectors: 6 | LongParameterList: 7 | enabled: false 8 | ModuleInitialize: 9 | exclude: 10 | - "HTMX::Headers" 11 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.7 2 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: Please use the following metadata when citing this project in your work. 3 | title: HTMX 4 | abstract: An augmenter and companion to the HTMX JavaScript library. 5 | version: 2.5.0 6 | license: Hippocratic-2.1 7 | date-released: 2025-09-16 8 | authors: 9 | - family-names: Kuhlmann 10 | given-names: Brooke 11 | affiliation: Alchemists 12 | orcid: https://orcid.org/0000-0002-5810-6268 13 | keywords: 14 | - ruby 15 | - htmx 16 | - rest 17 | - hypermedia 18 | repository-code: https://github.com/bkuhlmann/htmx 19 | repository-artifact: https://rubygems.org/gems/htmx 20 | url: https://alchemists.io/projects/htmx 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ruby file: ".ruby-version" 4 | 5 | source "https://rubygems.org" 6 | 7 | gemspec 8 | 9 | group :quality do 10 | gem "caliber", "~> 0.82" 11 | gem "git-lint", "~> 9.0" 12 | gem "reek", "~> 6.5", require: false 13 | gem "simplecov", "~> 0.22", require: false 14 | end 15 | 16 | group :development do 17 | gem "rake", "~> 13.3" 18 | end 19 | 20 | group :test do 21 | gem "rspec", "~> 3.13" 22 | end 23 | 24 | group :tools do 25 | gem "amazing_print", "~> 2.0" 26 | gem "debug", "~> 1.11" 27 | gem "irb-kit", "~> 1.1" 28 | gem "repl_type_completor", "~> 0.1" 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.adoc: -------------------------------------------------------------------------------- 1 | = Hippocratic License 2 | 3 | Version: 2.1.0. 4 | 5 | Purpose. The purpose of this License is for the Licensor named above to 6 | permit the Licensee (as defined below) broad permission, if consistent 7 | with Human Rights Laws and Human Rights Principles (as each is defined 8 | below), to use and work with the Software (as defined below) within the 9 | full scope of Licensor’s copyright and patent rights, if any, in the 10 | Software, while ensuring attribution and protecting the Licensor from 11 | liability. 12 | 13 | Permission and Conditions. The Licensor grants permission by this 14 | license ("License"), free of charge, to the extent of Licensor’s 15 | rights under applicable copyright and patent law, to any person or 16 | entity (the "Licensee") obtaining a copy of this software and 17 | associated documentation files (the "Software"), to do everything with 18 | the Software that would otherwise infringe (i) the Licensor’s copyright 19 | in the Software or (ii) any patent claims to the Software that the 20 | Licensor can license or becomes able to license, subject to all of the 21 | following terms and conditions: 22 | 23 | * Acceptance. This License is automatically offered to every person and 24 | entity subject to its terms and conditions. Licensee accepts this 25 | License and agrees to its terms and conditions by taking any action with 26 | the Software that, absent this License, would infringe any intellectual 27 | property right held by Licensor. 28 | * Notice. Licensee must ensure that everyone who gets a copy of any part 29 | of this Software from Licensee, with or without changes, also receives 30 | the License and the above copyright notice (and if included by the 31 | Licensor, patent, trademark and attribution notice). Licensee must cause 32 | any modified versions of the Software to carry prominent notices stating 33 | that Licensee changed the Software. For clarity, although Licensee is 34 | free to create modifications of the Software and distribute only the 35 | modified portion created by Licensee with additional or different terms, 36 | the portion of the Software not modified must be distributed pursuant to 37 | this License. If anyone notifies Licensee in writing that Licensee has 38 | not complied with this Notice section, Licensee can keep this License by 39 | taking all practical steps to comply within 30 days after the notice. If 40 | Licensee does not do so, Licensee’s License (and all rights licensed 41 | hereunder) shall end immediately. 42 | * Compliance with Human Rights Principles and Human Rights Laws. 43 | [arabic] 44 | . Human Rights Principles. 45 | [loweralpha] 46 | .. Licensee is advised to consult the articles of the United Nations 47 | Universal Declaration of Human Rights and the United Nations Global 48 | Compact that define recognized principles of international human rights 49 | (the "Human Rights Principles"). Licensee shall use the Software in a 50 | manner consistent with Human Rights Principles. 51 | .. Unless the Licensor and Licensee agree otherwise, any dispute, 52 | controversy, or claim arising out of or relating to (i) Section 1(a) 53 | regarding Human Rights Principles, including the breach of Section 1(a), 54 | termination of this License for breach of the Human Rights Principles, 55 | or invalidity of Section 1(a) or (ii) a determination of whether any Law 56 | is consistent or in conflict with Human Rights Principles pursuant to 57 | Section 2, below, shall be settled by arbitration in accordance with the 58 | Hague Rules on Business and Human Rights Arbitration (the "Rules"); 59 | provided, however, that Licensee may elect not to participate in such 60 | arbitration, in which event this License (and all rights licensed 61 | hereunder) shall end immediately. The number of arbitrators shall be one 62 | unless the Rules require otherwise. 63 | + 64 | Unless both the Licensor and Licensee agree to the contrary: (1) All 65 | documents and information concerning the arbitration shall be public and 66 | may be disclosed by any party; (2) The repository referred to under 67 | Article 43 of the Rules shall make available to the public in a timely 68 | manner all documents concerning the arbitration which are communicated 69 | to it, including all submissions of the parties, all evidence admitted 70 | into the record of the proceedings, all transcripts or other recordings 71 | of hearings and all orders, decisions and awards of the arbitral 72 | tribunal, subject only to the arbitral tribunal’s powers to take such 73 | measures as may be necessary to safeguard the integrity of the arbitral 74 | process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3) 75 | Article 26(6) of the Rules shall not apply. 76 | . Human Rights Laws. The Software shall not be used by any person or 77 | entity for any systems, activities, or other uses that violate any Human 78 | Rights Laws. "Human Rights Laws" means any applicable laws, 79 | regulations, or rules (collectively, "Laws") that protect human, 80 | civil, labor, privacy, political, environmental, security, economic, due 81 | process, or similar rights; provided, however, that such Laws are 82 | consistent and not in conflict with Human Rights Principles (a dispute 83 | over the consistency or a conflict between Laws and Human Rights 84 | Principles shall be determined by arbitration as stated above). Where 85 | the Human Rights Laws of more than one jurisdiction are applicable or in 86 | conflict with respect to the use of the Software, the Human Rights Laws 87 | that are most protective of the individuals or groups harmed shall 88 | apply. 89 | . Indemnity. Licensee shall hold harmless and indemnify Licensor (and 90 | any other contributor) against all losses, damages, liabilities, 91 | deficiencies, claims, actions, judgments, settlements, interest, awards, 92 | penalties, fines, costs, or expenses of whatever kind, including 93 | Licensor’s reasonable attorneys’ fees, arising out of or relating to 94 | Licensee’s use of the Software in violation of Human Rights Laws or 95 | Human Rights Principles. 96 | * Failure to Comply. Any failure of Licensee to act according to the 97 | terms and conditions of this License is both a breach of the License and 98 | an infringement of the intellectual property rights of the Licensor 99 | (subject to exceptions under Laws, e.g., fair use). In the event of a 100 | breach or infringement, the terms and conditions of this License may be 101 | enforced by Licensor under the Laws of any jurisdiction to which 102 | Licensee is subject. Licensee also agrees that the Licensor may enforce 103 | the terms and conditions of this License against Licensee through 104 | specific performance (or similar remedy under Laws) to the extent 105 | permitted by Laws. For clarity, except in the event of a breach of this 106 | License, infringement, or as otherwise stated in this License, Licensor 107 | may not terminate this License with Licensee. 108 | * Enforceability and Interpretation. If any term or provision of this 109 | License is determined to be invalid, illegal, or unenforceable by a 110 | court of competent jurisdiction, then such invalidity, illegality, or 111 | unenforceability shall not affect any other term or provision of this 112 | License or invalidate or render unenforceable such term or provision in 113 | any other jurisdiction; provided, however, subject to a court 114 | modification pursuant to the immediately following sentence, if any term 115 | or provision of this License pertaining to Human Rights Laws or Human 116 | Rights Principles is deemed invalid, illegal, or unenforceable against 117 | Licensee by a court of competent jurisdiction, all rights in the 118 | Software granted to Licensee shall be deemed null and void as between 119 | Licensor and Licensee. Upon a determination that any term or provision 120 | is invalid, illegal, or unenforceable, to the extent permitted by Laws, 121 | the court may modify this License to affect the original purpose that 122 | the Software be used in compliance with Human Rights Principles and 123 | Human Rights Laws as closely as possible. The language in this License 124 | shall be interpreted as to its fair meaning and not strictly for or 125 | against any party. 126 | * Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES 127 | "AS IS," WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND 128 | ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR 129 | OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE 130 | OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM. 131 | 132 | This Hippocratic License is an link:https://ethicalsource.dev[Ethical Source license] and is offered 133 | for use by licensors and licensees at their own risk, on an "AS IS" basis, and with no warranties 134 | express or implied, to the maximum extent permitted by Laws. 135 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :toc: macro 2 | :toclevels: 5 3 | :figure-caption!: 4 | 5 | :htmx_link: link:https://htmx.org[htmx] 6 | :hypermedia_systems_link: link:https://hypermedia.systems[Hypermedia Systems] 7 | :hanami_link: link:https://hanamirb.org[Hanami] 8 | :roda_link: link:http://roda.jeremyevans.net[Roda] 9 | :data_link: link:https://alchemists.io/articles/ruby_data[Data] 10 | :hanamismith_link: link:https://alchemists.io/projects/hanamismith[Hanamismith] 11 | 12 | = HTMX 13 | 14 | A haiku from the {htmx_link} team: 15 | 16 | .... 17 | javascript fatigue: 18 | longing for a hypertext 19 | already in hand 20 | .... 21 | 22 | ...and from {hypermedia_systems_link}: 23 | 24 | ____ 25 | The goal of [htmx] is not less JavaScript, but less code, more readable and hypermedia-friendly code. 26 | ____ 27 | 28 | 29 | This gem provides native Ruby support for the {htmx_link} JavaScript library so you can build sophisticated web applications using pure Hypermedia REST APIs while avoiding unnecessary bloat and complexity common with the JavaScript ecosystem. By building upon the original foundations of Hypermedia REST APIs, you can build rich web applications with no additional JavaScript! 30 | 31 | 💡 This is used with the {hanamismith_link} gem when building {hanami_link} applications. Even better, you can play with the link:https://github.com/bkuhlmann/hemo[Hanami demo application] to learn more. Enjoy! 32 | 33 | toc::[] 34 | 35 | == Features 36 | 37 | - Augments the {htmx_link} JavaScript library. 38 | - Speeds up {htmx_link} development. 39 | - Compatible with {hanami_link}, {roda_link}, and other web frameworks. 40 | 41 | == Requirements 42 | 43 | . A strong understanding of {hypermedia_systems_link}. 44 | . {htmx_link}. 45 | . link:https://www.ruby-lang.org[Ruby]. 46 | 47 | == Setup 48 | 49 | To install _with_ security, run: 50 | 51 | [source,bash] 52 | ---- 53 | # 💡 Skip this line if you already have the public certificate installed. 54 | gem cert --add <(curl --compressed --location https://alchemists.io/gems.pem) 55 | gem install htmx --trust-policy HighSecurity 56 | ---- 57 | 58 | To install _without_ security, run: 59 | 60 | [source,bash] 61 | ---- 62 | gem install htmx 63 | ---- 64 | 65 | You can also add the gem directly to your project: 66 | 67 | [source,bash] 68 | ---- 69 | bundle add htmx 70 | ---- 71 | 72 | Once the gem is installed, you only need to require it: 73 | 74 | [source,ruby] 75 | ---- 76 | require "htmx" 77 | ---- 78 | 79 | == Usage 80 | 81 | One of the first tasks you'll want to tackle, when working with the {htmx_link} library, is building htmx specific HTML attributes. This can be accomplished by using the `.[]` method. For example, when implementing a {hanami_link} view part, you could use the following: 82 | 83 | [source,ruby] 84 | ---- 85 | tag.button( 86 | "Delete", 87 | class: "button decline", 88 | type: "submit", 89 | **HTMX[target: "closest .task", delete: "/tasks/#{value.id}"] 90 | ) 91 | ---- 92 | 93 | The above would produce the following: 94 | 95 | [source,html] 96 | ---- 97 | 100 | ---- 101 | 102 | Notice the appropriate htmx `hx-target` and `hx-delete` attributes are built which are compatible with the {htmx_link} library while not having to manually prefix each one of these attributes with the `hx-` prefix. You can use symbols, strings, or a mix of both as well. 103 | 104 | === HTML Attribute Prefixes 105 | 106 | As shown above, building htmx attributes takes minimal effort but if you'd prefer the HTML `data-` prefix, which the {htmx_link} library supports, you can customize by using the following: 107 | 108 | [source,ruby] 109 | ---- 110 | prefixer = HTMX::Prefixer.new "data-hx" 111 | 112 | tag.button( 113 | "Delete", 114 | class: "button decline", 115 | type: "submit", 116 | **prefixer.call(target: "closest .task", delete: "/tasks/#{value.id}") 117 | ) 118 | ---- 119 | 120 | This would then produce the following HTML code: 121 | 122 | [source,html] 123 | ---- 124 | 131 | ---- 132 | 133 | As you can see, the `data-hx-target` and `data-hx-delete` keys are used. These are definitely more verbose than the `hx-` keys. By the way, the `HTMX::Prefixer` is called when using the htmx `.[]` as shown earlier. This means the following are equivalent: 134 | 135 | [source,ruby] 136 | ---- 137 | HTMX[delete: "/tasks/1"] 138 | HTMX::Prefixer.new.call delete: "/tasks/1" 139 | HTMX::Prefixer.new("hx").call delete: "/tasks/1" 140 | ---- 141 | 142 | All three of the above will produce the same output which means you'll most likely want to use the `.[]` method since it has the shortest syntax. 143 | 144 | If you attempt to use an unsupported prefix, you'll get an error: 145 | 146 | [source,ruby] 147 | ---- 148 | HTMX::Prefixer.new "bogus" 149 | # Invalid prefix: "bogus". Use: "hx" or "data-hx". (HTMX::Error) 150 | ---- 151 | 152 | Some {htmx_link} attributes use dashes. For those situations, you can use strings for keys or underscored symbols to produce the correct htmx syntax. Here's an example using both a string and symbol for keys: 153 | 154 | [source,ruby] 155 | ---- 156 | HTMX["swap-oob" => true, push_url: "/demo/123"] 157 | # {"hx-swap-oob"=>true, "hx-push-url"=>"/demo/123"} 158 | ---- 159 | 160 | === HTTP Headers 161 | 162 | When working with HTTP requests/responses, especially HTTP headers, there are a couple of methods that can parse and make the data easier to work with. Here's how to use them. 163 | 164 | ==== Requests 165 | 166 | The request object allows you to obtain a {data_link} object to interact with when parsing link:https://htmx.org/reference/#request_headers[htmx HTTP request headers]. Example: 167 | 168 | [source,ruby] 169 | ---- 170 | HTMX.request 171 | 172 | # 181 | ---- 182 | 183 | Notice you get a {data_link} instance where all members have the `HX-` prefix removed while each value defaults to `nil`. Even better -- and more practical -- is you can ask the request object to parse the incoming HTTP headers directly and give you _exactly_ what you need: 184 | 185 | [source,ruby] 186 | ---- 187 | HTMX.request "HTTP_HX_BOOSTED" => "true", 188 | "HTTP_HX_CURRENT_URL" => "/demo", 189 | "HTTP_HX_HISTORY_RESTORE_REQUEST" => "false", 190 | "HTTP_HX_PROMPT" => "Yes", 191 | "HTTP_HX_REQUEST" => "true", 192 | "HTTP_HX_TARGET" => "demo", 193 | "HTTP_HX_TRIGGER_NAME" => "save", 194 | "HTTP_HX_TRIGGER" => "demo" 195 | 196 | # 205 | ---- 206 | 207 | As you can see, this method only plucks out the htmx specific headers which may or may not have values. Extra header keys, which are not specific to {htmx_link}, are ignored. 208 | 209 | As an added convenience, you can use predicate methods for boolean values. Example: 210 | 211 | [source,ruby] 212 | ---- 213 | headers = HTMX.request "HTTP_HX_BOOSTED" => "true", 214 | "HTTP_HX_CURRENT_URL" => "/demo", 215 | "HTTP_HX_HISTORY_RESTORE_REQUEST" => "false", 216 | "HTTP_HX_PROMPT" => "Yes", 217 | "HTTP_HX_REQUEST" => "true", 218 | "HTTP_HX_TARGET" => "demo", 219 | "HTTP_HX_TRIGGER_NAME" => "save", 220 | "HTTP_HX_TRIGGER" => "demo" 221 | 222 | headers.boosted? # true 223 | headers.confirmed? # true 224 | headers.history_restore_request? # false 225 | headers.request? # true 226 | ---- 227 | 228 | Use of `#confirmed?` is the only unique predicate method since it answers a boolean based on the truthiness of the `HTTP_HX_PROMPT` header. For further details, see `String#truthy?` as provided by the link:https://alchemists.io/projects/refinements#_truthy[Refinements] gem. 229 | 230 | Due to `HTML.request` delegating to the `HTMX::Headers::Request`, this means you can use the object directly. Specifically, you can obtain the record, key, or header depending on your needs. Example: 231 | 232 | [source,ruby] 233 | ---- 234 | HTMX::Headers::Request.for(**headers) # Identical to `HTMX.request`. 235 | HTMX::Headers::Request.key_for "HTTP_HX_CURRENT_URL" # :current_url 236 | HTMX::Headers::Request.header_for :current_url # "HTTP_HX_CURRENT_URL" 237 | ---- 238 | 239 | Should you not want to use `HTMX::Headers::Request`, you can use the request predicate methods instead. Example: 240 | 241 | [source,ruby] 242 | ---- 243 | headers = {} 244 | 245 | HTMX.request! headers, boosted: true, prompt: "Yes" 246 | # {"HTTP_HX_BOOSTED" => true, "HTTP_HX_PROMPT" => "Yes"} 247 | 248 | HTMX.request? headers, :prompt, "Yes" # true 249 | HTMX.request? headers, :prompt, "On" # false 250 | ---- 251 | 252 | 💡 `HTMX.request!` is designed to mutate your original headers. Unknown attributes are merged as is. 253 | 254 | ==== Responses 255 | 256 | The response object allows you to obtain a {data_link} object to interact with when parsing link:https://htmx.org/reference/#response_headers[htmx HTTP response headers]. Example: 257 | 258 | [source,ruby] 259 | ---- 260 | HTMX.response 261 | 262 | # 273 | ---- 274 | 275 | Notice you get a {data_link} instance where all members have the `HX-` prefix removed while each value defaults to `nil`. Even better -- and more practical -- is you can ask the response object to parse the incoming HTTP headers directly and give you _exactly_ what you need: 276 | 277 | [source,ruby] 278 | ---- 279 | HTMX.response "HX-Location" => "/", 280 | "HX-Push-Url" => "/demo", 281 | "HX-Redirect" => "/demo", 282 | "HX-Refresh" => "true", 283 | "HX-Replace-Url" => "/demo", 284 | "HX-Reswap" => "none", 285 | "HX-Retarget" => ".demo", 286 | "HX-Trigger" => "demo", 287 | "HX-Trigger-After-Settle" => "demo", 288 | "HX-Trigger-After-Swap" => "demo" 289 | 290 | # 301 | ---- 302 | 303 | As you can see, this method only plucks out the htmx specific headers which may or may not have values. Extra header keys, which are not specific to {htmx_link}, are ignored. 304 | 305 | There is also a refresh predicate method that'll answer a boolean for convenience. Example: 306 | 307 | [source,ruby] 308 | ---- 309 | headers = HTMX.response "HX-Refresh" => "true" 310 | 311 | headers.refresh? # true 312 | ---- 313 | 314 | Due to `HTML.response` delegating to the `HTMX::Headers::Response`, this means you can use the object directly. Specifically, you can obtain the record, key, or header depending on your needs. Example: 315 | 316 | [source,ruby] 317 | ---- 318 | HTMX::Headers::Response.for(**headers) # Identical to `HTMX.response`. 319 | HTMX::Headers::Response.key_for "HX-Location" # :location 320 | HTMX::Headers::Response.header_for :location # "HX-Location" 321 | ---- 322 | 323 | Should you not want to use `HTMX::Headers::Response`, you can use the response predicate methods instead. Example: 324 | 325 | [source,ruby] 326 | ---- 327 | headers = {} 328 | 329 | HTMX.response! headers, location: "/", push_url: "/test" 330 | # {"HX-Location" => "/", "HX-Push-Url" => "/test"} 331 | 332 | HTMX.response? headers, :push_url, "/test" # true 333 | HTMX.response? headers, :push_url, "/other" # false 334 | ---- 335 | 336 | 💡 `HTMX.response!` is designed to mutate your original headers. Unknown attributes are merged as is. 337 | 338 | === Errors 339 | 340 | Any/all errors issued by this gem will be an instance of the `HTMX::Error` class which inherits from `StandardError`. You can use this classification to catch and deal with these errors in your own implementation as desired. 341 | 342 | == Development 343 | 344 | To contribute, run: 345 | 346 | [source,bash] 347 | ---- 348 | git clone https://github.com/bkuhlmann/htmx 349 | cd htmx 350 | bin/setup 351 | ---- 352 | 353 | You can also use the IRB console for direct access to all objects: 354 | 355 | [source,bash] 356 | ---- 357 | bin/console 358 | ---- 359 | 360 | == Tests 361 | 362 | To test, run: 363 | 364 | [source,bash] 365 | ---- 366 | bin/rake 367 | ---- 368 | 369 | == link:https://alchemists.io/policies/license[License] 370 | 371 | == link:https://alchemists.io/policies/security[Security] 372 | 373 | == link:https://alchemists.io/policies/code_of_conduct[Code of Conduct] 374 | 375 | == link:https://alchemists.io/policies/contributions[Contributions] 376 | 377 | == link:https://alchemists.io/policies/developer_certificate_of_origin[Developer Certificate of Origin] 378 | 379 | == link:https://alchemists.io/projects/htmx/versions[Versions] 380 | 381 | == link:https://alchemists.io/community[Community] 382 | 383 | == Credits 384 | 385 | * Built with link:https://alchemists.io/projects/gemsmith[Gemsmith]. 386 | * Engineered by link:https://alchemists.io/team/brooke_kuhlmann[Brooke Kuhlmann]. 387 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "git/lint/rake/register" 5 | require "reek/rake/task" 6 | require "rspec/core/rake_task" 7 | require "rubocop/rake_task" 8 | 9 | Git::Lint::Rake::Register.call 10 | Reek::Rake::Task.new 11 | RSpec::Core::RakeTask.new { |task| task.verbose = false } 12 | RuboCop::RakeTask.new 13 | 14 | desc "Run code quality checks" 15 | task quality: %i[git_lint reek rubocop] 16 | 17 | task default: %i[quality spec] 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | Bundler.require :tools 6 | 7 | require "htmx" 8 | require "irb" 9 | 10 | IRB.start __FILE__ 11 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rake", "rake" 7 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rspec-core", "rspec" 7 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | 6 | load Gem.bin_path "rubocop", "rubocop" 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "debug" 5 | require "fileutils" 6 | require "pathname" 7 | 8 | APP_ROOT = Pathname(__dir__).join("..").expand_path 9 | 10 | Runner = lambda do |*arguments, kernel: Kernel| 11 | kernel.system(*arguments) || kernel.abort("\nERROR: Command #{arguments.inspect} failed.") 12 | end 13 | 14 | FileUtils.chdir APP_ROOT do 15 | puts "Installing dependencies..." 16 | Runner.call "bundle install" 17 | end 18 | -------------------------------------------------------------------------------- /htmx.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "htmx" 5 | spec.version = "2.5.0" 6 | spec.authors = ["Brooke Kuhlmann"] 7 | spec.email = ["brooke@alchemists.io"] 8 | spec.homepage = "https://alchemists.io/projects/htmx" 9 | spec.summary = "An augmenter and companion to the HTMX JavaScript library." 10 | spec.license = "Hippocratic-2.1" 11 | 12 | spec.metadata = { 13 | "bug_tracker_uri" => "https://github.com/bkuhlmann/htmx/issues", 14 | "changelog_uri" => "https://alchemists.io/projects/htmx/versions", 15 | "homepage_uri" => "https://alchemists.io/projects/htmx", 16 | "funding_uri" => "https://github.com/sponsors/bkuhlmann", 17 | "label" => "HTMX", 18 | "rubygems_mfa_required" => "true", 19 | "source_code_uri" => "https://github.com/bkuhlmann/htmx" 20 | } 21 | 22 | spec.signing_key = Gem.default_key_path 23 | spec.cert_chain = [Gem.default_cert_path] 24 | 25 | spec.required_ruby_version = "~> 3.4" 26 | spec.add_dependency "refinements", "~> 13.5" 27 | spec.add_dependency "zeitwerk", "~> 2.7" 28 | 29 | spec.extra_rdoc_files = Dir["README*", "LICENSE*"] 30 | spec.files = Dir["*.gemspec", "lib/**/*"] 31 | end 32 | -------------------------------------------------------------------------------- /lib/htmx.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "zeitwerk" 4 | 5 | Zeitwerk::Loader.new.then do |loader| 6 | loader.inflector.inflect "htmx" => "HTMX" 7 | loader.tag = File.basename __FILE__, ".rb" 8 | loader.push_dir __dir__ 9 | loader.setup 10 | end 11 | 12 | # Main namespace. 13 | module HTMX 14 | REQUEST_MAP = { 15 | boosted: "HTTP_HX_BOOSTED", 16 | current_url: "HTTP_HX_CURRENT_URL", 17 | history_restore_request: "HTTP_HX_HISTORY_RESTORE_REQUEST", 18 | prompt: "HTTP_HX_PROMPT", 19 | request: "HTTP_HX_REQUEST", 20 | target: "HTTP_HX_TARGET", 21 | trigger_name: "HTTP_HX_TRIGGER_NAME", 22 | trigger: "HTTP_HX_TRIGGER" 23 | }.freeze 24 | 25 | RESPONSE_MAP = { 26 | location: "HX-Location", 27 | push_url: "HX-Push-Url", 28 | redirect: "HX-Redirect", 29 | refresh: "HX-Refresh", 30 | replace_url: "HX-Replace-Url", 31 | reswap: "HX-Reswap", 32 | retarget: "HX-Retarget", 33 | trigger: "HX-Trigger", 34 | trigger_after_settle: "HX-Trigger-After-Settle", 35 | trigger_after_swap: "HX-Trigger-After-Swap" 36 | }.freeze 37 | 38 | def self.loader registry = Zeitwerk::Registry 39 | @loader ||= registry.loaders.each.find { |loader| loader.tag == File.basename(__FILE__, ".rb") } 40 | end 41 | 42 | def self.[](...) 43 | @prefixer ||= Prefixer.new 44 | @prefixer.call(...) 45 | end 46 | 47 | def self.request(**) = Headers::Request.for(**) 48 | 49 | def self.request!(headers, **attributes) = headers.merge! attributes.transform_keys!(REQUEST_MAP) 50 | 51 | def self.request?(headers, key, value) = headers[REQUEST_MAP[key]] == value 52 | 53 | def self.response(**) = Headers::Response.for(**) 54 | 55 | def self.response!(headers, **attributes) 56 | headers.merge! attributes.transform_keys!(RESPONSE_MAP) 57 | end 58 | 59 | def self.response?(headers, key, value) = headers[RESPONSE_MAP[key]] == value 60 | end 61 | -------------------------------------------------------------------------------- /lib/htmx/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTMX 4 | # The root error for all errors within this library. 5 | class Error < StandardError 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/htmx/headers/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "refinements/string" 4 | 5 | module HTMX 6 | module Headers 7 | # Models the supported HTMX request headers. 8 | Request = Data.define(*REQUEST_MAP.keys) do 9 | using Refinements::String 10 | 11 | def self.for(key_map: REQUEST_MAP.invert, **attributes) 12 | new(**attributes.slice(*key_map.keys).transform_keys!(key_map)) 13 | end 14 | 15 | def self.key_for(header, key_map: REQUEST_MAP.invert) = key_map.fetch header 16 | 17 | def self.header_for(key, key_map: REQUEST_MAP) = key_map.fetch key 18 | 19 | def initialize boosted: nil, 20 | current_url: nil, 21 | history_restore_request: nil, 22 | prompt: nil, 23 | request: nil, 24 | target: nil, 25 | trigger_name: nil, 26 | trigger: nil 27 | super 28 | end 29 | 30 | def boosted? = boosted == "true" 31 | 32 | def confirmed? = prompt ? prompt.truthy? : false 33 | 34 | def history_restore_request? = history_restore_request == "true" 35 | 36 | def request? = request == "true" 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/htmx/headers/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HTMX 4 | module Headers 5 | # Models the supported HTMX response headers. 6 | Response = Data.define(*RESPONSE_MAP.keys) do 7 | def self.for(key_map: RESPONSE_MAP.invert, **attributes) 8 | new(**attributes.slice(*key_map.keys).transform_keys!(key_map)) 9 | end 10 | 11 | def self.key_for(header, key_map: RESPONSE_MAP.invert) = key_map.fetch header 12 | 13 | def self.header_for(key, key_map: RESPONSE_MAP) = key_map.fetch key 14 | 15 | def initialize location: nil, 16 | push_url: nil, 17 | redirect: nil, 18 | refresh: nil, 19 | replace_url: nil, 20 | reswap: nil, 21 | retarget: nil, 22 | trigger: nil, 23 | trigger_after_settle: nil, 24 | trigger_after_swap: nil 25 | super 26 | end 27 | 28 | def refresh? = refresh == "true" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/htmx/prefixer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "refinements/array" 4 | 5 | module HTMX 6 | # Prefixes HTML element attributes for proper consumption by the HTMX JavaScript library. 7 | class Prefixer 8 | using Refinements::Array 9 | 10 | ALLOWED = %w[hx data-hx].freeze 11 | 12 | def initialize default = "hx", allowed: ALLOWED 13 | @default = default 14 | @allowed = allowed 15 | validate 16 | end 17 | 18 | def call(**attributes) = attributes.transform_keys! { |key| "#{default}-#{key}".tr "_", "-" } 19 | 20 | private 21 | 22 | attr_reader :default, :allowed 23 | 24 | def validate 25 | return true if allowed.include? default 26 | 27 | fail Error, %(Invalid prefix: #{default.inspect}. Use: #{allowed.to_usage "or"}.) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/lib/htmx/headers/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe HTMX::Headers::Request do 6 | subject(:request) { described_class.new } 7 | 8 | describe ".for" do 9 | it "answers all supported headers" do 10 | request = described_class.for "HTTP_HX_BOOSTED" => "true", 11 | "HTTP_HX_CURRENT_URL" => "/test", 12 | "HTTP_HX_HISTORY_RESTORE_REQUEST" => "false", 13 | "HTTP_HX_PROMPT" => "Yes", 14 | "HTTP_HX_REQUEST" => "true", 15 | "HTTP_HX_TARGET" => "test", 16 | "HTTP_HX_TRIGGER_NAME" => "save", 17 | "HTTP_HX_TRIGGER" => "test" 18 | expect(request).to eq( 19 | described_class[ 20 | boosted: "true", 21 | current_url: "/test", 22 | history_restore_request: "false", 23 | prompt: "Yes", 24 | request: "true", 25 | target: "test", 26 | trigger_name: "save", 27 | trigger: "test" 28 | ] 29 | ) 30 | end 31 | 32 | it "ignores unsupported headers" do 33 | response = described_class.for "HTTP_HX_BOOSTED" => "true", 34 | "HTTP_HX_BAD" => "danger", 35 | "other" => "else" 36 | expect(response).to eq(described_class[boosted: "true"]) 37 | end 38 | end 39 | 40 | describe ".key_for" do 41 | it "answers key for header" do 42 | expect(described_class.key_for("HTTP_HX_TRIGGER")).to eq(:trigger) 43 | end 44 | 45 | it "fails with invalid key" do 46 | expectation = proc { described_class.key_for "BOGUS" } 47 | expect(&expectation).to raise_error(KeyError, /BOGUS/) 48 | end 49 | end 50 | 51 | describe ".header_for" do 52 | it "answers header for key" do 53 | expect(described_class.header_for(:trigger)).to eq("HTTP_HX_TRIGGER") 54 | end 55 | 56 | it "fails with invalid key" do 57 | expectation = proc { described_class.key_for :bogus } 58 | expect(&expectation).to raise_error(KeyError, /:bogus/) 59 | end 60 | end 61 | 62 | describe "#initialize" do 63 | it "answers default attributes" do 64 | expect(request).to eq( 65 | described_class[ 66 | boosted: nil, 67 | current_url: nil, 68 | history_restore_request: nil, 69 | prompt: nil, 70 | request: nil, 71 | target: nil, 72 | trigger_name: nil, 73 | trigger: nil 74 | ] 75 | ) 76 | end 77 | end 78 | 79 | describe "#boosted?" do 80 | it "answers true when enabled" do 81 | expect(described_class[boosted: "true"].boosted?).to be(true) 82 | end 83 | 84 | it "answers false when disabled" do 85 | expect(request.boosted?).to be(false) 86 | end 87 | end 88 | 89 | describe "#confirmed?" do 90 | it "answers true when prompt is truthy" do 91 | expect(described_class[prompt: "Yes"].confirmed?).to be(true) 92 | end 93 | 94 | it "answers false when prompt is nil" do 95 | expect(request.confirmed?).to be(false) 96 | end 97 | 98 | it "answers false when prompt is falsey" do 99 | expect(described_class[prompt: "nope"].confirmed?).to be(false) 100 | end 101 | end 102 | 103 | describe "#history_restore_request?" do 104 | it "answers true when enabled" do 105 | expect(described_class[history_restore_request: "true"].history_restore_request?).to be(true) 106 | end 107 | 108 | it "answers false when disabled" do 109 | expect(request.history_restore_request?).to be(false) 110 | end 111 | end 112 | 113 | describe "#request?" do 114 | it "answers true when enabled" do 115 | expect(described_class[request: "true"].request?).to be(true) 116 | end 117 | 118 | it "answers false when disabled" do 119 | expect(request.request?).to be(false) 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /spec/lib/htmx/headers/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe HTMX::Headers::Response do 6 | subject(:response) { described_class.new } 7 | 8 | describe ".for" do 9 | it "answers all supported headers" do 10 | response = described_class.for "HX-Location" => "/", 11 | "HX-Push-Url" => "/test", 12 | "HX-Redirect" => "/test", 13 | "HX-Refresh" => "true", 14 | "HX-Replace-Url" => "/test", 15 | "HX-Reswap" => "none", 16 | "HX-Retarget" => ".test", 17 | "HX-Trigger" => "test", 18 | "HX-Trigger-After-Settle" => "test", 19 | "HX-Trigger-After-Swap" => "test" 20 | 21 | expect(response).to eq( 22 | described_class[ 23 | location: "/", 24 | push_url: "/test", 25 | redirect: "/test", 26 | refresh: "true", 27 | replace_url: "/test", 28 | reswap: "none", 29 | retarget: ".test", 30 | trigger: "test", 31 | trigger_after_settle: "test", 32 | trigger_after_swap: "test" 33 | ] 34 | ) 35 | end 36 | 37 | it "ignores unsupported headers" do 38 | response = described_class.for "HX-Location" => "/", "HX-Bad" => "danger", "other" => "else" 39 | expect(response).to eq(described_class[location: "/"]) 40 | end 41 | end 42 | 43 | describe ".key_for" do 44 | it "answers key for header" do 45 | expect(described_class.key_for("HX-Redirect")).to eq(:redirect) 46 | end 47 | 48 | it "fails with invalid key" do 49 | expectation = proc { described_class.key_for "Bogus" } 50 | expect(&expectation).to raise_error(KeyError, /Bogus/) 51 | end 52 | end 53 | 54 | describe ".header_for" do 55 | it "answers header for key" do 56 | expect(described_class.header_for(:redirect)).to eq("HX-Redirect") 57 | end 58 | 59 | it "fails with invalid key" do 60 | expectation = proc { described_class.key_for :bogus } 61 | expect(&expectation).to raise_error(KeyError, /:bogus/) 62 | end 63 | end 64 | 65 | describe "#initialize" do 66 | it "answers default attributes" do 67 | expect(response).to eq( 68 | described_class[ 69 | location: nil, 70 | push_url: nil, 71 | redirect: nil, 72 | refresh: nil, 73 | replace_url: nil, 74 | reswap: nil, 75 | retarget: nil, 76 | trigger: nil, 77 | trigger_after_settle: nil, 78 | trigger_after_swap: nil 79 | ] 80 | ) 81 | end 82 | end 83 | 84 | describe "#refresh?" do 85 | it "answers true when enabled" do 86 | expect(described_class[refresh: "true"].refresh?).to be(true) 87 | end 88 | 89 | it "answers false when disabled" do 90 | expect(response.refresh?).to be(false) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/lib/htmx/prefixer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe HTMX::Prefixer do 6 | subject(:prefixer) { described_class.new } 7 | 8 | describe "#initialize" do 9 | it "allows default prefix" do 10 | expectation = proc { described_class.new } 11 | expect(&expectation).not_to raise_error 12 | end 13 | 14 | it %(allows "hx" prefix) do 15 | expectation = proc { described_class.new "hx" } 16 | expect(&expectation).not_to raise_error 17 | end 18 | 19 | it %(allows "data-hx" prefix) do 20 | expectation = proc { described_class.new "data-hx" } 21 | expect(&expectation).not_to raise_error 22 | end 23 | 24 | it "fails with invalid prefix" do 25 | expectation = proc { described_class.new "danger" } 26 | 27 | expect(&expectation).to raise_error( 28 | HTMX::Error, 29 | %(Invalid prefix: "danger". Use: "hx" or "data-hx".) 30 | ) 31 | end 32 | end 33 | 34 | describe "#call" do 35 | it "answers prefixed attributes" do 36 | attributes = prefixer.call "get" => "/tasks", trigger: "click" 37 | expect(attributes).to eq("hx-get" => "/tasks", "hx-trigger" => "click") 38 | end 39 | 40 | it "answers prefixed attributes with dashes" do 41 | attributes = prefixer.call "push_url" => true, swap_oob: true 42 | expect(attributes).to eq("hx-push-url" => true, "hx-swap-oob" => true) 43 | end 44 | 45 | it "answers empty hash with no arguments" do 46 | expect(prefixer.call).to eq({}) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/lib/htmx_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe HTMX do 6 | describe ".loader" do 7 | it "eager loads" do 8 | expectation = proc { described_class.loader.eager_load force: true } 9 | expect(&expectation).not_to raise_error 10 | end 11 | 12 | it "answers unique tag" do 13 | expect(described_class.loader.tag).to eq("htmx") 14 | end 15 | end 16 | 17 | describe ".[]" do 18 | it "answers prefixed attributes" do 19 | attributes = described_class["get" => "/tasks", trigger: "click"] 20 | expect(attributes).to eq("hx-get" => "/tasks", "hx-trigger" => "click") 21 | end 22 | 23 | it "answers empty hash with no arguments" do 24 | expect(described_class[]).to eq({}) 25 | end 26 | end 27 | 28 | describe ".request" do 29 | it "answers request header data" do 30 | result = described_class.request "HTTP_HX_BOOSTED" => "true", "HTTP_HX_PROMPT" => "Yes" 31 | expect(result).to eq(HTMX::Headers::Request[boosted: "true", prompt: "Yes"]) 32 | end 33 | end 34 | 35 | describe ".request!" do 36 | it "mutates request headers" do 37 | headers = {} 38 | described_class.request! headers, boosted: true, prompt: "Yes" 39 | 40 | expect(headers).to eq("HTTP_HX_BOOSTED" => true, "HTTP_HX_PROMPT" => "Yes") 41 | end 42 | 43 | it "mutates request headers by passing unknown keys through" do 44 | headers = {} 45 | described_class.request! headers, bogus: true 46 | 47 | expect(headers).to eq(bogus: true) 48 | end 49 | 50 | it "answers empty hash when there is nothing to mutate" do 51 | expect(described_class.request!({})).to eq({}) 52 | end 53 | end 54 | 55 | describe ".request?" do 56 | let(:headers) { {"HTTP_HX_CURRENT_URL" => "/test"} } 57 | 58 | it "answers true when value matches" do 59 | expect(described_class.request?(headers, :current_url, "/test")).to be(true) 60 | end 61 | 62 | it "answers false when value doesn't match" do 63 | expect(described_class.request?(headers, :current_url, "/other")).to be(false) 64 | end 65 | end 66 | 67 | describe ".response" do 68 | it "answers response header data" do 69 | result = described_class.response "HX-Redirect" => "/test", "HX-Reswap" => "none" 70 | expect(result).to eq(HTMX::Headers::Response[redirect: "/test", reswap: "none"]) 71 | end 72 | end 73 | 74 | describe ".response!" do 75 | it "mutates response headers" do 76 | headers = {} 77 | described_class.response! headers, location: "/", push_url: "/test" 78 | 79 | expect(headers).to eq("HX-Location" => "/", "HX-Push-Url" => "/test") 80 | end 81 | 82 | it "mutates response headers by passing unknown keys through" do 83 | headers = {} 84 | described_class.response! headers, bogus: true 85 | 86 | expect(headers).to eq(bogus: true) 87 | end 88 | 89 | it "answers empty hash when there is nothing to mutate" do 90 | expect(described_class.response!({})).to eq({}) 91 | end 92 | end 93 | 94 | describe ".response?" do 95 | let(:headers) { {"HX-Push-Url" => "/test"} } 96 | 97 | it "answers true when value matches" do 98 | expect(described_class.response?(headers, :push_url, "/test")).to be(true) 99 | end 100 | 101 | it "answers false when value doesn't match" do 102 | expect(described_class.response?(headers, :push_url, "/other")).to be(false) 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | if ENV["COVERAGE"] == "no" 6 | puts "SimpleCov skipped due to being disabled." 7 | else 8 | SimpleCov.start do 9 | add_filter %r(^/spec/) 10 | enable_coverage :branch 11 | enable_coverage_for_eval 12 | minimum_coverage_by_file line: 95, branch: 95 13 | end 14 | end 15 | 16 | Bundler.require :tools 17 | 18 | require "htmx" 19 | require "refinements" 20 | 21 | SPEC_ROOT = Pathname(__dir__).realpath.freeze 22 | 23 | using Refinements::Pathname 24 | 25 | Pathname.require_tree SPEC_ROOT.join("support/shared_contexts") 26 | 27 | RSpec.configure do |config| 28 | config.color = true 29 | config.disable_monkey_patching! 30 | config.example_status_persistence_file_path = "./tmp/rspec-examples.txt" 31 | config.filter_run_when_matching :focus 32 | config.formatter = ENV.fetch("CI", false) == "true" ? :progress : :documentation 33 | config.order = :random 34 | config.pending_failure_output = :no_backtrace 35 | config.shared_context_metadata_behavior = :apply_to_host_groups 36 | config.warnings = true 37 | 38 | config.expect_with :rspec do |expectations| 39 | expectations.syntax = :expect 40 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 41 | end 42 | 43 | config.mock_with :rspec do |mocks| 44 | mocks.verify_doubled_constant_names = true 45 | mocks.verify_partial_doubles = true 46 | end 47 | 48 | Kernel.srand config.seed 49 | end 50 | -------------------------------------------------------------------------------- /spec/support/shared_contexts/temp_dir.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_context "with temporary directory" do 4 | using Refinements::Pathname 5 | 6 | let(:temp_dir) { Bundler.root.join "tmp/rspec" } 7 | 8 | around do |example| 9 | temp_dir.make_path 10 | example.run 11 | temp_dir.remove_tree 12 | end 13 | end 14 | --------------------------------------------------------------------------------