├── .env.TEMPLATE ├── .gitattributes ├── .gitignore ├── Cargo.toml ├── README.md ├── assets ├── scripts │ └── index.ts ├── styles │ └── index.css └── vendored │ └── htmx@1.9.12.js ├── build.rs ├── public └── robots.txt ├── rustfmt.toml ├── src ├── api_error.rs ├── asset_cache.rs ├── config.rs ├── main.rs ├── routes │ ├── index.rs │ ├── mod.rs │ ├── not_found.rs │ └── robots.rs └── state.rs ├── tailwind.config.js └── templates ├── _base.html ├── _partial.html ├── about.html ├── index.html ├── navbar.html └── not_found.html /.env.TEMPLATE: -------------------------------------------------------------------------------- 1 | HOST=127.0.0.1 2 | PORT=8000 3 | CORS_ORIGIN=http://127.0.0.1:8000 4 | POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:5432/postgres 5 | REDIS_URL=redis://127.0.0.1:6379 6 | ENCRYPTION_KEY= -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | assets/vendored/**/* linguist-vendored -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .cargo/ 3 | .turbo/ 4 | build/ 5 | data/ 6 | dist/ 7 | node_modules/ 8 | target/ 9 | 10 | # Files 11 | .env 12 | .env.development 13 | .env.production 14 | .log 15 | Cargo.lock 16 | pnpm-lock.yaml 17 | *.woff2 18 | 19 | # User Settings 20 | .idea 21 | .vscode -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "template-axum-htmx-tailwind" 3 | authors = ["Rob Wagner "] 4 | license = "" 5 | repository = "https://github.com/robertwayne/template-axum-htmx-tailwind" 6 | version = "0.1.0" 7 | edition = "2021" 8 | publish = false 9 | 10 | [dependencies] 11 | async-compression = { version = "0.4", features = ["brotli"] } 12 | axum = { version = "0.7" } 13 | axum-extra = { version = "0.9", default-features = false, features = [ 14 | "cookie", 15 | "cookie-private", 16 | ] } 17 | axum-htmx = { version = "0.5" } 18 | bytes = "1" 19 | deadpool = { version = "0.12", features = ["rt_tokio_1", "managed"] } 20 | deadpool-postgres = "0.13" 21 | minijinja = { git = "https://github.com/mitsuhiko/minijinja", branch = "main", features = [ 22 | "loader", 23 | ] } 24 | serde = { version = "1", features = ["derive"] } 25 | tokio = { version = "1", features = ["rt-multi-thread", "macros"] } 26 | tokio-postgres = "0.7" 27 | tower = { version = "0.4", default-features = false, features = ["util"] } 28 | tower-http = { version = "0.5", default-features = false, features = [ 29 | "cors", 30 | "compression-br", 31 | ] } 32 | tracing = { version = "0.1", default-features = false, features = ["std"] } 33 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 34 | 35 | [profile.release] 36 | codegen-units = 1 37 | lto = true 38 | opt-level = 3 39 | strip = true 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Template: axum + htmx + tailwind 2 | 3 | ## Backend 4 | 5 | - __[Axum](https://github.com/tokio-rs/axum)__ 6 | - __[PostgreSQL](https://www.postgresql.org)__ 7 | - __[minijinja](https://docs.rs/minijinja/latest/minijinja/)__ 8 | 9 | ## Frontend 10 | 11 | - __[htmx](https://htmx.org)__ 12 | - __[TypeScript](https://www.typescriptlang.org)__ 13 | - __[Tailwind](https://tailwindcss.com)__ 14 | - __[bun](https://bun.sh/)__ 15 | 16 | ## Getting Started 17 | 18 | _This is an experimental template that I use as a base for my personal projects. 19 | There are a lot of opinionated and probably controversial design choices that 20 | I've made here. I cannot recommend using this template for your own projects, 21 | especially if you're new to Rust._ 22 | 23 | ## Notes 24 | 25 | - Internally caches asset files. JavaScript and CSS files are pre-compressed at 26 | startup with `brotli` with a max compression level. 27 | - Compresses HTML fragments with `brotli` at a lower compression level via 28 | `tower-compression` at runtime. 29 | - Sets Cache-Control headers for CSS, JS, WEBP, SVG, and WOFF2 by default. 30 | - Uses `bun` via the `build.rs` script to minify, hash, and bundle JS/TS/CSS. 31 | - Run with `cargo watch -x run` to automatically rebuild on asset / source 32 | changes. 33 | 34 | ## Other Templates 35 | 36 | - __[Axum + SolidJS](https://github.com/robertwayne/template-axum-solidjs-spa)__ 37 | - __[Rocket + 38 | Svelte](https://github.com/robertwayne/template-rocket-svelte-spa)__ 39 | -------------------------------------------------------------------------------- /assets/scripts/index.ts: -------------------------------------------------------------------------------- 1 | import "../vendored/htmx@1.9.10.js" 2 | import "../../build/index.css" 3 | -------------------------------------------------------------------------------- /assets/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /assets/vendored/htmx@1.9.12.js: -------------------------------------------------------------------------------- 1 | // UMD insanity 2 | // This code sets up support for (in order) AMD, ES6 modules, and globals. 3 | (function (root, factory) { 4 | //@ts-ignore 5 | if (typeof define === 'function' && define.amd) { 6 | // AMD. Register as an anonymous module. 7 | //@ts-ignore 8 | define([], factory); 9 | } else if (typeof module === 'object' && module.exports) { 10 | // Node. Does not work with strict CommonJS, but 11 | // only CommonJS-like environments that support module.exports, 12 | // like Node. 13 | module.exports = factory(); 14 | } else { 15 | // Browser globals 16 | root.htmx = root.htmx || factory(); 17 | } 18 | }(typeof self !== 'undefined' ? self : this, function () { 19 | return (function () { 20 | 'use strict'; 21 | 22 | // Public API 23 | //** @type {import("./htmx").HtmxApi} */ 24 | // TODO: list all methods in public API 25 | var htmx = { 26 | onLoad: onLoadHelper, 27 | process: processNode, 28 | on: addEventListenerImpl, 29 | off: removeEventListenerImpl, 30 | trigger : triggerEvent, 31 | ajax : ajaxHelper, 32 | find : find, 33 | findAll : findAll, 34 | closest : closest, 35 | values : function(elt, type){ 36 | var inputValues = getInputValues(elt, type || "post"); 37 | return inputValues.values; 38 | }, 39 | remove : removeElement, 40 | addClass : addClassToElement, 41 | removeClass : removeClassFromElement, 42 | toggleClass : toggleClassOnElement, 43 | takeClass : takeClassForElement, 44 | defineExtension : defineExtension, 45 | removeExtension : removeExtension, 46 | logAll : logAll, 47 | logNone : logNone, 48 | logger : null, 49 | config : { 50 | historyEnabled:true, 51 | historyCacheSize:10, 52 | refreshOnHistoryMiss:false, 53 | defaultSwapStyle:'innerHTML', 54 | defaultSwapDelay:0, 55 | defaultSettleDelay:20, 56 | includeIndicatorStyles:true, 57 | indicatorClass:'htmx-indicator', 58 | requestClass:'htmx-request', 59 | addedClass:'htmx-added', 60 | settlingClass:'htmx-settling', 61 | swappingClass:'htmx-swapping', 62 | allowEval:true, 63 | allowScriptTags:true, 64 | inlineScriptNonce:'', 65 | attributesToSettle:["class", "style", "width", "height"], 66 | withCredentials:false, 67 | timeout:0, 68 | wsReconnectDelay: 'full-jitter', 69 | wsBinaryType: 'blob', 70 | disableSelector: "[hx-disable], [data-hx-disable]", 71 | useTemplateFragments: false, 72 | scrollBehavior: 'smooth', 73 | defaultFocusScroll: false, 74 | getCacheBusterParam: false, 75 | globalViewTransitions: false, 76 | methodsThatUseUrlParams: ["get"], 77 | selfRequestsOnly: false, 78 | ignoreTitle: false, 79 | scrollIntoViewOnBoost: true, 80 | triggerSpecsCache: null, 81 | }, 82 | parseInterval:parseInterval, 83 | _:internalEval, 84 | createEventSource: function(url){ 85 | return new EventSource(url, {withCredentials:true}) 86 | }, 87 | createWebSocket: function(url){ 88 | var sock = new WebSocket(url, []); 89 | sock.binaryType = htmx.config.wsBinaryType; 90 | return sock; 91 | }, 92 | version: "1.9.12" 93 | }; 94 | 95 | /** @type {import("./htmx").HtmxInternalApi} */ 96 | var internalAPI = { 97 | addTriggerHandler: addTriggerHandler, 98 | bodyContains: bodyContains, 99 | canAccessLocalStorage: canAccessLocalStorage, 100 | findThisElement: findThisElement, 101 | filterValues: filterValues, 102 | hasAttribute: hasAttribute, 103 | getAttributeValue: getAttributeValue, 104 | getClosestAttributeValue: getClosestAttributeValue, 105 | getClosestMatch: getClosestMatch, 106 | getExpressionVars: getExpressionVars, 107 | getHeaders: getHeaders, 108 | getInputValues: getInputValues, 109 | getInternalData: getInternalData, 110 | getSwapSpecification: getSwapSpecification, 111 | getTriggerSpecs: getTriggerSpecs, 112 | getTarget: getTarget, 113 | makeFragment: makeFragment, 114 | mergeObjects: mergeObjects, 115 | makeSettleInfo: makeSettleInfo, 116 | oobSwap: oobSwap, 117 | querySelectorExt: querySelectorExt, 118 | selectAndSwap: selectAndSwap, 119 | settleImmediately: settleImmediately, 120 | shouldCancel: shouldCancel, 121 | triggerEvent: triggerEvent, 122 | triggerErrorEvent: triggerErrorEvent, 123 | withExtensions: withExtensions, 124 | } 125 | 126 | var VERBS = ['get', 'post', 'put', 'delete', 'patch']; 127 | var VERB_SELECTOR = VERBS.map(function(verb){ 128 | return "[hx-" + verb + "], [data-hx-" + verb + "]" 129 | }).join(", "); 130 | 131 | var HEAD_TAG_REGEX = makeTagRegEx('head'), 132 | TITLE_TAG_REGEX = makeTagRegEx('title'), 133 | SVG_TAGS_REGEX = makeTagRegEx('svg', true); 134 | 135 | //==================================================================== 136 | // Utilities 137 | //==================================================================== 138 | 139 | /** 140 | * @param {string} tag 141 | * @param {boolean} [global] 142 | * @returns {RegExp} 143 | */ 144 | function makeTagRegEx(tag, global) { 145 | return new RegExp('<' + tag + '(\\s[^>]*>|>)([\\s\\S]*?)<\\/' + tag + '>', 146 | !!global ? 'gim' : 'im') 147 | } 148 | 149 | function parseInterval(str) { 150 | if (str == undefined) { 151 | return undefined; 152 | } 153 | 154 | let interval = NaN; 155 | if (str.slice(-2) == "ms") { 156 | interval = parseFloat(str.slice(0, -2)); 157 | } else if (str.slice(-1) == "s") { 158 | interval = parseFloat(str.slice(0, -1)) * 1000; 159 | } else if (str.slice(-1) == "m") { 160 | interval = parseFloat(str.slice(0, -1)) * 1000 * 60; 161 | } else { 162 | interval = parseFloat(str); 163 | } 164 | return isNaN(interval) ? undefined : interval; 165 | } 166 | 167 | /** 168 | * @param {HTMLElement} elt 169 | * @param {string} name 170 | * @returns {(string | null)} 171 | */ 172 | function getRawAttribute(elt, name) { 173 | return elt.getAttribute && elt.getAttribute(name); 174 | } 175 | 176 | // resolve with both hx and data-hx prefixes 177 | function hasAttribute(elt, qualifiedName) { 178 | return elt.hasAttribute && (elt.hasAttribute(qualifiedName) || 179 | elt.hasAttribute("data-" + qualifiedName)); 180 | } 181 | 182 | /** 183 | * 184 | * @param {HTMLElement} elt 185 | * @param {string} qualifiedName 186 | * @returns {(string | null)} 187 | */ 188 | function getAttributeValue(elt, qualifiedName) { 189 | return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, "data-" + qualifiedName); 190 | } 191 | 192 | /** 193 | * @param {HTMLElement} elt 194 | * @returns {HTMLElement | null} 195 | */ 196 | function parentElt(elt) { 197 | return elt.parentElement; 198 | } 199 | 200 | /** 201 | * @returns {Document} 202 | */ 203 | function getDocument() { 204 | return document; 205 | } 206 | 207 | /** 208 | * @param {HTMLElement} elt 209 | * @param {(e:HTMLElement) => boolean} condition 210 | * @returns {HTMLElement | null} 211 | */ 212 | function getClosestMatch(elt, condition) { 213 | while (elt && !condition(elt)) { 214 | elt = parentElt(elt); 215 | } 216 | 217 | return elt ? elt : null; 218 | } 219 | 220 | function getAttributeValueWithDisinheritance(initialElement, ancestor, attributeName){ 221 | var attributeValue = getAttributeValue(ancestor, attributeName); 222 | var disinherit = getAttributeValue(ancestor, "hx-disinherit"); 223 | if (initialElement !== ancestor && disinherit && (disinherit === "*" || disinherit.split(" ").indexOf(attributeName) >= 0)) { 224 | return "unset"; 225 | } else { 226 | return attributeValue 227 | } 228 | } 229 | 230 | /** 231 | * @param {HTMLElement} elt 232 | * @param {string} attributeName 233 | * @returns {string | null} 234 | */ 235 | function getClosestAttributeValue(elt, attributeName) { 236 | var closestAttr = null; 237 | getClosestMatch(elt, function (e) { 238 | return closestAttr = getAttributeValueWithDisinheritance(elt, e, attributeName); 239 | }); 240 | if (closestAttr !== "unset") { 241 | return closestAttr; 242 | } 243 | } 244 | 245 | /** 246 | * @param {HTMLElement} elt 247 | * @param {string} selector 248 | * @returns {boolean} 249 | */ 250 | function matches(elt, selector) { 251 | // @ts-ignore: non-standard properties for browser compatibility 252 | // noinspection JSUnresolvedVariable 253 | var matchesFunction = elt.matches || elt.matchesSelector || elt.msMatchesSelector || elt.mozMatchesSelector || elt.webkitMatchesSelector || elt.oMatchesSelector; 254 | return matchesFunction && matchesFunction.call(elt, selector); 255 | } 256 | 257 | /** 258 | * @param {string} str 259 | * @returns {string} 260 | */ 261 | function getStartTag(str) { 262 | var tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i 263 | var match = tagMatcher.exec( str ); 264 | if (match) { 265 | return match[1].toLowerCase(); 266 | } else { 267 | return ""; 268 | } 269 | } 270 | 271 | /** 272 | * 273 | * @param {string} resp 274 | * @param {number} depth 275 | * @returns {Element} 276 | */ 277 | function parseHTML(resp, depth) { 278 | var parser = new DOMParser(); 279 | var responseDoc = parser.parseFromString(resp, "text/html"); 280 | 281 | /** @type {Element} */ 282 | var responseNode = responseDoc.body; 283 | while (depth > 0) { 284 | depth--; 285 | // @ts-ignore 286 | responseNode = responseNode.firstChild; 287 | } 288 | if (responseNode == null) { 289 | // @ts-ignore 290 | responseNode = getDocument().createDocumentFragment(); 291 | } 292 | return responseNode; 293 | } 294 | 295 | function aFullPageResponse(resp) { 296 | return /", 0); 313 | // @ts-ignore type mismatch between DocumentFragment and Element. 314 | // TODO: Are these close enough for htmx to use interchangeably? 315 | var fragmentContent = fragment.querySelector('template').content; 316 | if (htmx.config.allowScriptTags) { 317 | // if there is a nonce set up, set it on the new script tags 318 | forEach(fragmentContent.querySelectorAll("script"), function (script) { 319 | if (htmx.config.inlineScriptNonce) { 320 | script.nonce = htmx.config.inlineScriptNonce; 321 | } 322 | // mark as executed due to template insertion semantics on all browsers except firefox fml 323 | script.htmxExecuted = navigator.userAgent.indexOf("Firefox") === -1; 324 | }) 325 | } else { 326 | forEach(fragmentContent.querySelectorAll("script"), function (script) { 327 | // remove all script tags if scripts are disabled 328 | removeElement(script); 329 | }) 330 | } 331 | return fragmentContent; 332 | } 333 | switch (startTag) { 334 | case "thead": 335 | case "tbody": 336 | case "tfoot": 337 | case "colgroup": 338 | case "caption": 339 | return parseHTML("" + content + "
", 1); 340 | case "col": 341 | return parseHTML("" + content + "
", 2); 342 | case "tr": 343 | return parseHTML("" + content + "
", 2); 344 | case "td": 345 | case "th": 346 | return parseHTML("" + content + "
", 3); 347 | case "script": 348 | case "style": 349 | return parseHTML("
" + content + "
", 1); 350 | default: 351 | return parseHTML(content, 0); 352 | } 353 | } 354 | 355 | /** 356 | * @param {Function} func 357 | */ 358 | function maybeCall(func){ 359 | if(func) { 360 | func(); 361 | } 362 | } 363 | 364 | /** 365 | * @param {any} o 366 | * @param {string} type 367 | * @returns 368 | */ 369 | function isType(o, type) { 370 | return Object.prototype.toString.call(o) === "[object " + type + "]"; 371 | } 372 | 373 | /** 374 | * @param {*} o 375 | * @returns {o is Function} 376 | */ 377 | function isFunction(o) { 378 | return isType(o, "Function"); 379 | } 380 | 381 | /** 382 | * @param {*} o 383 | * @returns {o is Object} 384 | */ 385 | function isRawObject(o) { 386 | return isType(o, "Object"); 387 | } 388 | 389 | /** 390 | * getInternalData retrieves "private" data stored by htmx within an element 391 | * @param {HTMLElement} elt 392 | * @returns {*} 393 | */ 394 | function getInternalData(elt) { 395 | var dataProp = 'htmx-internal-data'; 396 | var data = elt[dataProp]; 397 | if (!data) { 398 | data = elt[dataProp] = {}; 399 | } 400 | return data; 401 | } 402 | 403 | /** 404 | * toArray converts an ArrayLike object into a real array. 405 | * @param {ArrayLike} arr 406 | * @returns {any[]} 407 | */ 408 | function toArray(arr) { 409 | var returnArr = []; 410 | if (arr) { 411 | for (var i = 0; i < arr.length; i++) { 412 | returnArr.push(arr[i]); 413 | } 414 | } 415 | return returnArr 416 | } 417 | 418 | function forEach(arr, func) { 419 | if (arr) { 420 | for (var i = 0; i < arr.length; i++) { 421 | func(arr[i]); 422 | } 423 | } 424 | } 425 | 426 | function isScrolledIntoView(el) { 427 | var rect = el.getBoundingClientRect(); 428 | var elemTop = rect.top; 429 | var elemBottom = rect.bottom; 430 | return elemTop < window.innerHeight && elemBottom >= 0; 431 | } 432 | 433 | function bodyContains(elt) { 434 | // IE Fix 435 | if (elt.getRootNode && elt.getRootNode() instanceof window.ShadowRoot) { 436 | return getDocument().body.contains(elt.getRootNode().host); 437 | } else { 438 | return getDocument().body.contains(elt); 439 | } 440 | } 441 | 442 | function splitOnWhitespace(trigger) { 443 | return trigger.trim().split(/\s+/); 444 | } 445 | 446 | /** 447 | * mergeObjects takes all of the keys from 448 | * obj2 and duplicates them into obj1 449 | * @param {Object} obj1 450 | * @param {Object} obj2 451 | * @returns {Object} 452 | */ 453 | function mergeObjects(obj1, obj2) { 454 | for (var key in obj2) { 455 | if (obj2.hasOwnProperty(key)) { 456 | obj1[key] = obj2[key]; 457 | } 458 | } 459 | return obj1; 460 | } 461 | 462 | function parseJSON(jString) { 463 | try { 464 | return JSON.parse(jString); 465 | } catch(error) { 466 | logError(error); 467 | return null; 468 | } 469 | } 470 | 471 | function canAccessLocalStorage() { 472 | var test = 'htmx:localStorageTest'; 473 | try { 474 | localStorage.setItem(test, test); 475 | localStorage.removeItem(test); 476 | return true; 477 | } catch(e) { 478 | return false; 479 | } 480 | } 481 | 482 | function normalizePath(path) { 483 | try { 484 | var url = new URL(path); 485 | if (url) { 486 | path = url.pathname + url.search; 487 | } 488 | // remove trailing slash, unless index page 489 | if (!(/^\/$/.test(path))) { 490 | path = path.replace(/\/+$/, ''); 491 | } 492 | return path; 493 | } catch (e) { 494 | // be kind to IE11, which doesn't support URL() 495 | return path; 496 | } 497 | } 498 | 499 | //========================================================================================== 500 | // public API 501 | //========================================================================================== 502 | 503 | function internalEval(str){ 504 | return maybeEval(getDocument().body, function () { 505 | return eval(str); 506 | }); 507 | } 508 | 509 | function onLoadHelper(callback) { 510 | var value = htmx.on("htmx:load", function(evt) { 511 | callback(evt.detail.elt); 512 | }); 513 | return value; 514 | } 515 | 516 | function logAll(){ 517 | htmx.logger = function(elt, event, data) { 518 | if(console) { 519 | console.log(event, elt, data); 520 | } 521 | } 522 | } 523 | 524 | function logNone() { 525 | htmx.logger = null 526 | } 527 | 528 | function find(eltOrSelector, selector) { 529 | if (selector) { 530 | return eltOrSelector.querySelector(selector); 531 | } else { 532 | return find(getDocument(), eltOrSelector); 533 | } 534 | } 535 | 536 | function findAll(eltOrSelector, selector) { 537 | if (selector) { 538 | return eltOrSelector.querySelectorAll(selector); 539 | } else { 540 | return findAll(getDocument(), eltOrSelector); 541 | } 542 | } 543 | 544 | function removeElement(elt, delay) { 545 | elt = resolveTarget(elt); 546 | if (delay) { 547 | setTimeout(function(){ 548 | removeElement(elt); 549 | elt = null; 550 | }, delay); 551 | } else { 552 | elt.parentElement.removeChild(elt); 553 | } 554 | } 555 | 556 | function addClassToElement(elt, clazz, delay) { 557 | elt = resolveTarget(elt); 558 | if (delay) { 559 | setTimeout(function(){ 560 | addClassToElement(elt, clazz); 561 | elt = null; 562 | }, delay); 563 | } else { 564 | elt.classList && elt.classList.add(clazz); 565 | } 566 | } 567 | 568 | function removeClassFromElement(elt, clazz, delay) { 569 | elt = resolveTarget(elt); 570 | if (delay) { 571 | setTimeout(function(){ 572 | removeClassFromElement(elt, clazz); 573 | elt = null; 574 | }, delay); 575 | } else { 576 | if (elt.classList) { 577 | elt.classList.remove(clazz); 578 | // if there are no classes left, remove the class attribute 579 | if (elt.classList.length === 0) { 580 | elt.removeAttribute("class"); 581 | } 582 | } 583 | } 584 | } 585 | 586 | function toggleClassOnElement(elt, clazz) { 587 | elt = resolveTarget(elt); 588 | elt.classList.toggle(clazz); 589 | } 590 | 591 | function takeClassForElement(elt, clazz) { 592 | elt = resolveTarget(elt); 593 | forEach(elt.parentElement.children, function(child){ 594 | removeClassFromElement(child, clazz); 595 | }) 596 | addClassToElement(elt, clazz); 597 | } 598 | 599 | function closest(elt, selector) { 600 | elt = resolveTarget(elt); 601 | if (elt.closest) { 602 | return elt.closest(selector); 603 | } else { 604 | // TODO remove when IE goes away 605 | do{ 606 | if (elt == null || matches(elt, selector)){ 607 | return elt; 608 | } 609 | } 610 | while (elt = elt && parentElt(elt)); 611 | return null; 612 | } 613 | } 614 | 615 | function startsWith(str, prefix) { 616 | return str.substring(0, prefix.length) === prefix 617 | } 618 | 619 | function endsWith(str, suffix) { 620 | return str.substring(str.length - suffix.length) === suffix 621 | } 622 | 623 | function normalizeSelector(selector) { 624 | var trimmedSelector = selector.trim(); 625 | if (startsWith(trimmedSelector, "<") && endsWith(trimmedSelector, "/>")) { 626 | return trimmedSelector.substring(1, trimmedSelector.length - 2); 627 | } else { 628 | return trimmedSelector; 629 | } 630 | } 631 | 632 | function querySelectorAllExt(elt, selector) { 633 | if (selector.indexOf("closest ") === 0) { 634 | return [closest(elt, normalizeSelector(selector.substr(8)))]; 635 | } else if (selector.indexOf("find ") === 0) { 636 | return [find(elt, normalizeSelector(selector.substr(5)))]; 637 | } else if (selector === "next") { 638 | return [elt.nextElementSibling] 639 | } else if (selector.indexOf("next ") === 0) { 640 | return [scanForwardQuery(elt, normalizeSelector(selector.substr(5)))]; 641 | } else if (selector === "previous") { 642 | return [elt.previousElementSibling] 643 | } else if (selector.indexOf("previous ") === 0) { 644 | return [scanBackwardsQuery(elt, normalizeSelector(selector.substr(9)))]; 645 | } else if (selector === 'document') { 646 | return [document]; 647 | } else if (selector === 'window') { 648 | return [window]; 649 | } else if (selector === 'body') { 650 | return [document.body]; 651 | } else { 652 | return getDocument().querySelectorAll(normalizeSelector(selector)); 653 | } 654 | } 655 | 656 | var scanForwardQuery = function(start, match) { 657 | var results = getDocument().querySelectorAll(match); 658 | for (var i = 0; i < results.length; i++) { 659 | var elt = results[i]; 660 | if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_PRECEDING) { 661 | return elt; 662 | } 663 | } 664 | } 665 | 666 | var scanBackwardsQuery = function(start, match) { 667 | var results = getDocument().querySelectorAll(match); 668 | for (var i = results.length - 1; i >= 0; i--) { 669 | var elt = results[i]; 670 | if (elt.compareDocumentPosition(start) === Node.DOCUMENT_POSITION_FOLLOWING) { 671 | return elt; 672 | } 673 | } 674 | } 675 | 676 | function querySelectorExt(eltOrSelector, selector) { 677 | if (selector) { 678 | return querySelectorAllExt(eltOrSelector, selector)[0]; 679 | } else { 680 | return querySelectorAllExt(getDocument().body, eltOrSelector)[0]; 681 | } 682 | } 683 | 684 | function resolveTarget(arg2) { 685 | if (isType(arg2, 'String')) { 686 | return find(arg2); 687 | } else { 688 | return arg2; 689 | } 690 | } 691 | 692 | function processEventArgs(arg1, arg2, arg3) { 693 | if (isFunction(arg2)) { 694 | return { 695 | target: getDocument().body, 696 | event: arg1, 697 | listener: arg2 698 | } 699 | } else { 700 | return { 701 | target: resolveTarget(arg1), 702 | event: arg2, 703 | listener: arg3 704 | } 705 | } 706 | 707 | } 708 | 709 | function addEventListenerImpl(arg1, arg2, arg3) { 710 | ready(function(){ 711 | var eventArgs = processEventArgs(arg1, arg2, arg3); 712 | eventArgs.target.addEventListener(eventArgs.event, eventArgs.listener); 713 | }) 714 | var b = isFunction(arg2); 715 | return b ? arg2 : arg3; 716 | } 717 | 718 | function removeEventListenerImpl(arg1, arg2, arg3) { 719 | ready(function(){ 720 | var eventArgs = processEventArgs(arg1, arg2, arg3); 721 | eventArgs.target.removeEventListener(eventArgs.event, eventArgs.listener); 722 | }) 723 | return isFunction(arg2) ? arg2 : arg3; 724 | } 725 | 726 | //==================================================================== 727 | // Node processing 728 | //==================================================================== 729 | 730 | var DUMMY_ELT = getDocument().createElement("output"); // dummy element for bad selectors 731 | function findAttributeTargets(elt, attrName) { 732 | var attrTarget = getClosestAttributeValue(elt, attrName); 733 | if (attrTarget) { 734 | if (attrTarget === "this") { 735 | return [findThisElement(elt, attrName)]; 736 | } else { 737 | var result = querySelectorAllExt(elt, attrTarget); 738 | if (result.length === 0) { 739 | logError('The selector "' + attrTarget + '" on ' + attrName + " returned no matches!"); 740 | return [DUMMY_ELT] 741 | } else { 742 | return result; 743 | } 744 | } 745 | } 746 | } 747 | 748 | function findThisElement(elt, attribute){ 749 | return getClosestMatch(elt, function (elt) { 750 | return getAttributeValue(elt, attribute) != null; 751 | }) 752 | } 753 | 754 | function getTarget(elt) { 755 | var targetStr = getClosestAttributeValue(elt, "hx-target"); 756 | if (targetStr) { 757 | if (targetStr === "this") { 758 | return findThisElement(elt,'hx-target'); 759 | } else { 760 | return querySelectorExt(elt, targetStr) 761 | } 762 | } else { 763 | var data = getInternalData(elt); 764 | if (data.boosted) { 765 | return getDocument().body; 766 | } else { 767 | return elt; 768 | } 769 | } 770 | } 771 | 772 | function shouldSettleAttribute(name) { 773 | var attributesToSettle = htmx.config.attributesToSettle; 774 | for (var i = 0; i < attributesToSettle.length; i++) { 775 | if (name === attributesToSettle[i]) { 776 | return true; 777 | } 778 | } 779 | return false; 780 | } 781 | 782 | function cloneAttributes(mergeTo, mergeFrom) { 783 | forEach(mergeTo.attributes, function (attr) { 784 | if (!mergeFrom.hasAttribute(attr.name) && shouldSettleAttribute(attr.name)) { 785 | mergeTo.removeAttribute(attr.name) 786 | } 787 | }); 788 | forEach(mergeFrom.attributes, function (attr) { 789 | if (shouldSettleAttribute(attr.name)) { 790 | mergeTo.setAttribute(attr.name, attr.value); 791 | } 792 | }); 793 | } 794 | 795 | function isInlineSwap(swapStyle, target) { 796 | var extensions = getExtensions(target); 797 | for (var i = 0; i < extensions.length; i++) { 798 | var extension = extensions[i]; 799 | try { 800 | if (extension.isInlineSwap(swapStyle)) { 801 | return true; 802 | } 803 | } catch(e) { 804 | logError(e); 805 | } 806 | } 807 | return swapStyle === "outerHTML"; 808 | } 809 | 810 | /** 811 | * 812 | * @param {string} oobValue 813 | * @param {HTMLElement} oobElement 814 | * @param {*} settleInfo 815 | * @returns 816 | */ 817 | function oobSwap(oobValue, oobElement, settleInfo) { 818 | var selector = "#" + getRawAttribute(oobElement, "id"); 819 | var swapStyle = "outerHTML"; 820 | if (oobValue === "true") { 821 | // do nothing 822 | } else if (oobValue.indexOf(":") > 0) { 823 | swapStyle = oobValue.substr(0, oobValue.indexOf(":")); 824 | selector = oobValue.substr(oobValue.indexOf(":") + 1, oobValue.length); 825 | } else { 826 | swapStyle = oobValue; 827 | } 828 | 829 | var targets = getDocument().querySelectorAll(selector); 830 | if (targets) { 831 | forEach( 832 | targets, 833 | function (target) { 834 | var fragment; 835 | var oobElementClone = oobElement.cloneNode(true); 836 | fragment = getDocument().createDocumentFragment(); 837 | fragment.appendChild(oobElementClone); 838 | if (!isInlineSwap(swapStyle, target)) { 839 | fragment = oobElementClone; // if this is not an inline swap, we use the content of the node, not the node itself 840 | } 841 | 842 | var beforeSwapDetails = {shouldSwap: true, target: target, fragment:fragment }; 843 | if (!triggerEvent(target, 'htmx:oobBeforeSwap', beforeSwapDetails)) return; 844 | 845 | target = beforeSwapDetails.target; // allow re-targeting 846 | if (beforeSwapDetails['shouldSwap']){ 847 | swap(swapStyle, target, target, fragment, settleInfo); 848 | } 849 | forEach(settleInfo.elts, function (elt) { 850 | triggerEvent(elt, 'htmx:oobAfterSwap', beforeSwapDetails); 851 | }); 852 | } 853 | ); 854 | oobElement.parentNode.removeChild(oobElement); 855 | } else { 856 | oobElement.parentNode.removeChild(oobElement); 857 | triggerErrorEvent(getDocument().body, "htmx:oobErrorNoTarget", {content: oobElement}); 858 | } 859 | return oobValue; 860 | } 861 | 862 | function handleOutOfBandSwaps(elt, fragment, settleInfo) { 863 | var oobSelects = getClosestAttributeValue(elt, "hx-select-oob"); 864 | if (oobSelects) { 865 | var oobSelectValues = oobSelects.split(","); 866 | for (var i = 0; i < oobSelectValues.length; i++) { 867 | var oobSelectValue = oobSelectValues[i].split(":", 2); 868 | var id = oobSelectValue[0].trim(); 869 | if (id.indexOf("#") === 0) { 870 | id = id.substring(1); 871 | } 872 | var oobValue = oobSelectValue[1] || "true"; 873 | var oobElement = fragment.querySelector("#" + id); 874 | if (oobElement) { 875 | oobSwap(oobValue, oobElement, settleInfo); 876 | } 877 | } 878 | } 879 | forEach(findAll(fragment, '[hx-swap-oob], [data-hx-swap-oob]'), function (oobElement) { 880 | var oobValue = getAttributeValue(oobElement, "hx-swap-oob"); 881 | if (oobValue != null) { 882 | oobSwap(oobValue, oobElement, settleInfo); 883 | } 884 | }); 885 | } 886 | 887 | function handlePreservedElements(fragment) { 888 | forEach(findAll(fragment, '[hx-preserve], [data-hx-preserve]'), function (preservedElt) { 889 | var id = getAttributeValue(preservedElt, "id"); 890 | var oldElt = getDocument().getElementById(id); 891 | if (oldElt != null) { 892 | preservedElt.parentNode.replaceChild(oldElt, preservedElt); 893 | } 894 | }); 895 | } 896 | 897 | function handleAttributes(parentNode, fragment, settleInfo) { 898 | forEach(fragment.querySelectorAll("[id]"), function (newNode) { 899 | var id = getRawAttribute(newNode, "id") 900 | if (id && id.length > 0) { 901 | var normalizedId = id.replace("'", "\\'"); 902 | var normalizedTag = newNode.tagName.replace(':', '\\:'); 903 | var oldNode = parentNode.querySelector(normalizedTag + "[id='" + normalizedId + "']"); 904 | if (oldNode && oldNode !== parentNode) { 905 | var newAttributes = newNode.cloneNode(); 906 | cloneAttributes(newNode, oldNode); 907 | settleInfo.tasks.push(function () { 908 | cloneAttributes(newNode, newAttributes); 909 | }); 910 | } 911 | } 912 | }); 913 | } 914 | 915 | function makeAjaxLoadTask(child) { 916 | return function () { 917 | removeClassFromElement(child, htmx.config.addedClass); 918 | processNode(child); 919 | processScripts(child); 920 | processFocus(child) 921 | triggerEvent(child, 'htmx:load'); 922 | }; 923 | } 924 | 925 | function processFocus(child) { 926 | var autofocus = "[autofocus]"; 927 | var autoFocusedElt = matches(child, autofocus) ? child : child.querySelector(autofocus) 928 | if (autoFocusedElt != null) { 929 | autoFocusedElt.focus(); 930 | } 931 | } 932 | 933 | function insertNodesBefore(parentNode, insertBefore, fragment, settleInfo) { 934 | handleAttributes(parentNode, fragment, settleInfo); 935 | while(fragment.childNodes.length > 0){ 936 | var child = fragment.firstChild; 937 | addClassToElement(child, htmx.config.addedClass); 938 | parentNode.insertBefore(child, insertBefore); 939 | if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.COMMENT_NODE) { 940 | settleInfo.tasks.push(makeAjaxLoadTask(child)); 941 | } 942 | } 943 | } 944 | 945 | // based on https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0, 946 | // derived from Java's string hashcode implementation 947 | function stringHash(string, hash) { 948 | var char = 0; 949 | while (char < string.length){ 950 | hash = (hash << 5) - hash + string.charCodeAt(char++) | 0; // bitwise or ensures we have a 32-bit int 951 | } 952 | return hash; 953 | } 954 | 955 | function attributeHash(elt) { 956 | var hash = 0; 957 | // IE fix 958 | if (elt.attributes) { 959 | for (var i = 0; i < elt.attributes.length; i++) { 960 | var attribute = elt.attributes[i]; 961 | if(attribute.value){ // only include attributes w/ actual values (empty is same as non-existent) 962 | hash = stringHash(attribute.name, hash); 963 | hash = stringHash(attribute.value, hash); 964 | } 965 | } 966 | } 967 | return hash; 968 | } 969 | 970 | function deInitOnHandlers(elt) { 971 | var internalData = getInternalData(elt); 972 | if (internalData.onHandlers) { 973 | for (var i = 0; i < internalData.onHandlers.length; i++) { 974 | const handlerInfo = internalData.onHandlers[i]; 975 | elt.removeEventListener(handlerInfo.event, handlerInfo.listener); 976 | } 977 | delete internalData.onHandlers 978 | } 979 | } 980 | 981 | function deInitNode(element) { 982 | var internalData = getInternalData(element); 983 | if (internalData.timeout) { 984 | clearTimeout(internalData.timeout); 985 | } 986 | if (internalData.webSocket) { 987 | internalData.webSocket.close(); 988 | } 989 | if (internalData.sseEventSource) { 990 | internalData.sseEventSource.close(); 991 | } 992 | if (internalData.listenerInfos) { 993 | forEach(internalData.listenerInfos, function (info) { 994 | if (info.on) { 995 | info.on.removeEventListener(info.trigger, info.listener); 996 | } 997 | }); 998 | } 999 | deInitOnHandlers(element); 1000 | forEach(Object.keys(internalData), function(key) { delete internalData[key] }); 1001 | } 1002 | 1003 | function cleanUpElement(element) { 1004 | triggerEvent(element, "htmx:beforeCleanupElement") 1005 | deInitNode(element); 1006 | if (element.children) { // IE 1007 | forEach(element.children, function(child) { cleanUpElement(child) }); 1008 | } 1009 | } 1010 | 1011 | function swapOuterHTML(target, fragment, settleInfo) { 1012 | if (target.tagName === "BODY") { 1013 | return swapInnerHTML(target, fragment, settleInfo); 1014 | } else { 1015 | // @type {HTMLElement} 1016 | var newElt 1017 | var eltBeforeNewContent = target.previousSibling; 1018 | insertNodesBefore(parentElt(target), target, fragment, settleInfo); 1019 | if (eltBeforeNewContent == null) { 1020 | newElt = parentElt(target).firstChild; 1021 | } else { 1022 | newElt = eltBeforeNewContent.nextSibling; 1023 | } 1024 | settleInfo.elts = settleInfo.elts.filter(function(e) { return e != target }); 1025 | while(newElt && newElt !== target) { 1026 | if (newElt.nodeType === Node.ELEMENT_NODE) { 1027 | settleInfo.elts.push(newElt); 1028 | } 1029 | newElt = newElt.nextElementSibling; 1030 | } 1031 | cleanUpElement(target); 1032 | parentElt(target).removeChild(target); 1033 | } 1034 | } 1035 | 1036 | function swapAfterBegin(target, fragment, settleInfo) { 1037 | return insertNodesBefore(target, target.firstChild, fragment, settleInfo); 1038 | } 1039 | 1040 | function swapBeforeBegin(target, fragment, settleInfo) { 1041 | return insertNodesBefore(parentElt(target), target, fragment, settleInfo); 1042 | } 1043 | 1044 | function swapBeforeEnd(target, fragment, settleInfo) { 1045 | return insertNodesBefore(target, null, fragment, settleInfo); 1046 | } 1047 | 1048 | function swapAfterEnd(target, fragment, settleInfo) { 1049 | return insertNodesBefore(parentElt(target), target.nextSibling, fragment, settleInfo); 1050 | } 1051 | function swapDelete(target, fragment, settleInfo) { 1052 | cleanUpElement(target); 1053 | return parentElt(target).removeChild(target); 1054 | } 1055 | 1056 | function swapInnerHTML(target, fragment, settleInfo) { 1057 | var firstChild = target.firstChild; 1058 | insertNodesBefore(target, firstChild, fragment, settleInfo); 1059 | if (firstChild) { 1060 | while (firstChild.nextSibling) { 1061 | cleanUpElement(firstChild.nextSibling) 1062 | target.removeChild(firstChild.nextSibling); 1063 | } 1064 | cleanUpElement(firstChild) 1065 | target.removeChild(firstChild); 1066 | } 1067 | } 1068 | 1069 | function maybeSelectFromResponse(elt, fragment, selectOverride) { 1070 | var selector = selectOverride || getClosestAttributeValue(elt, "hx-select"); 1071 | if (selector) { 1072 | var newFragment = getDocument().createDocumentFragment(); 1073 | forEach(fragment.querySelectorAll(selector), function (node) { 1074 | newFragment.appendChild(node); 1075 | }); 1076 | fragment = newFragment; 1077 | } 1078 | return fragment; 1079 | } 1080 | 1081 | function swap(swapStyle, elt, target, fragment, settleInfo) { 1082 | switch (swapStyle) { 1083 | case "none": 1084 | return; 1085 | case "outerHTML": 1086 | swapOuterHTML(target, fragment, settleInfo); 1087 | return; 1088 | case "afterbegin": 1089 | swapAfterBegin(target, fragment, settleInfo); 1090 | return; 1091 | case "beforebegin": 1092 | swapBeforeBegin(target, fragment, settleInfo); 1093 | return; 1094 | case "beforeend": 1095 | swapBeforeEnd(target, fragment, settleInfo); 1096 | return; 1097 | case "afterend": 1098 | swapAfterEnd(target, fragment, settleInfo); 1099 | return; 1100 | case "delete": 1101 | swapDelete(target, fragment, settleInfo); 1102 | return; 1103 | default: 1104 | var extensions = getExtensions(elt); 1105 | for (var i = 0; i < extensions.length; i++) { 1106 | var ext = extensions[i]; 1107 | try { 1108 | var newElements = ext.handleSwap(swapStyle, target, fragment, settleInfo); 1109 | if (newElements) { 1110 | if (typeof newElements.length !== 'undefined') { 1111 | // if handleSwap returns an array (like) of elements, we handle them 1112 | for (var j = 0; j < newElements.length; j++) { 1113 | var child = newElements[j]; 1114 | if (child.nodeType !== Node.TEXT_NODE && child.nodeType !== Node.COMMENT_NODE) { 1115 | settleInfo.tasks.push(makeAjaxLoadTask(child)); 1116 | } 1117 | } 1118 | } 1119 | return; 1120 | } 1121 | } catch (e) { 1122 | logError(e); 1123 | } 1124 | } 1125 | if (swapStyle === "innerHTML") { 1126 | swapInnerHTML(target, fragment, settleInfo); 1127 | } else { 1128 | swap(htmx.config.defaultSwapStyle, elt, target, fragment, settleInfo); 1129 | } 1130 | } 1131 | } 1132 | 1133 | function findTitle(content) { 1134 | if (content.indexOf(' -1) { 1135 | var contentWithSvgsRemoved = content.replace(SVG_TAGS_REGEX, ''); 1136 | var result = contentWithSvgsRemoved.match(TITLE_TAG_REGEX); 1137 | if (result) { 1138 | return result[2]; 1139 | } 1140 | } 1141 | } 1142 | 1143 | function selectAndSwap(swapStyle, target, elt, responseText, settleInfo, selectOverride) { 1144 | settleInfo.title = findTitle(responseText); 1145 | var fragment = makeFragment(responseText); 1146 | if (fragment) { 1147 | handleOutOfBandSwaps(elt, fragment, settleInfo); 1148 | fragment = maybeSelectFromResponse(elt, fragment, selectOverride); 1149 | handlePreservedElements(fragment); 1150 | return swap(swapStyle, elt, target, fragment, settleInfo); 1151 | } 1152 | } 1153 | 1154 | function handleTrigger(xhr, header, elt) { 1155 | var triggerBody = xhr.getResponseHeader(header); 1156 | if (triggerBody.indexOf("{") === 0) { 1157 | var triggers = parseJSON(triggerBody); 1158 | for (var eventName in triggers) { 1159 | if (triggers.hasOwnProperty(eventName)) { 1160 | var detail = triggers[eventName]; 1161 | if (!isRawObject(detail)) { 1162 | detail = {"value": detail} 1163 | } 1164 | triggerEvent(elt, eventName, detail); 1165 | } 1166 | } 1167 | } else { 1168 | var eventNames = triggerBody.split(",") 1169 | for (var i = 0; i < eventNames.length; i++) { 1170 | triggerEvent(elt, eventNames[i].trim(), []); 1171 | } 1172 | } 1173 | } 1174 | 1175 | var WHITESPACE = /\s/; 1176 | var WHITESPACE_OR_COMMA = /[\s,]/; 1177 | var SYMBOL_START = /[_$a-zA-Z]/; 1178 | var SYMBOL_CONT = /[_$a-zA-Z0-9]/; 1179 | var STRINGISH_START = ['"', "'", "/"]; 1180 | var NOT_WHITESPACE = /[^\s]/; 1181 | var COMBINED_SELECTOR_START = /[{(]/; 1182 | var COMBINED_SELECTOR_END = /[})]/; 1183 | function tokenizeString(str) { 1184 | var tokens = []; 1185 | var position = 0; 1186 | while (position < str.length) { 1187 | if(SYMBOL_START.exec(str.charAt(position))) { 1188 | var startPosition = position; 1189 | while (SYMBOL_CONT.exec(str.charAt(position + 1))) { 1190 | position++; 1191 | } 1192 | tokens.push(str.substr(startPosition, position - startPosition + 1)); 1193 | } else if (STRINGISH_START.indexOf(str.charAt(position)) !== -1) { 1194 | var startChar = str.charAt(position); 1195 | var startPosition = position; 1196 | position++; 1197 | while (position < str.length && str.charAt(position) !== startChar ) { 1198 | if (str.charAt(position) === "\\") { 1199 | position++; 1200 | } 1201 | position++; 1202 | } 1203 | tokens.push(str.substr(startPosition, position - startPosition + 1)); 1204 | } else { 1205 | var symbol = str.charAt(position); 1206 | tokens.push(symbol); 1207 | } 1208 | position++; 1209 | } 1210 | return tokens; 1211 | } 1212 | 1213 | function isPossibleRelativeReference(token, last, paramName) { 1214 | return SYMBOL_START.exec(token.charAt(0)) && 1215 | token !== "true" && 1216 | token !== "false" && 1217 | token !== "this" && 1218 | token !== paramName && 1219 | last !== "."; 1220 | } 1221 | 1222 | function maybeGenerateConditional(elt, tokens, paramName) { 1223 | if (tokens[0] === '[') { 1224 | tokens.shift(); 1225 | var bracketCount = 1; 1226 | var conditionalSource = " return (function(" + paramName + "){ return ("; 1227 | var last = null; 1228 | while (tokens.length > 0) { 1229 | var token = tokens[0]; 1230 | if (token === "]") { 1231 | bracketCount--; 1232 | if (bracketCount === 0) { 1233 | if (last === null) { 1234 | conditionalSource = conditionalSource + "true"; 1235 | } 1236 | tokens.shift(); 1237 | conditionalSource += ")})"; 1238 | try { 1239 | var conditionFunction = maybeEval(elt,function () { 1240 | return Function(conditionalSource)(); 1241 | }, 1242 | function(){return true}) 1243 | conditionFunction.source = conditionalSource; 1244 | return conditionFunction; 1245 | } catch (e) { 1246 | triggerErrorEvent(getDocument().body, "htmx:syntax:error", {error:e, source:conditionalSource}) 1247 | return null; 1248 | } 1249 | } 1250 | } else if (token === "[") { 1251 | bracketCount++; 1252 | } 1253 | if (isPossibleRelativeReference(token, last, paramName)) { 1254 | conditionalSource += "((" + paramName + "." + token + ") ? (" + paramName + "." + token + ") : (window." + token + "))"; 1255 | } else { 1256 | conditionalSource = conditionalSource + token; 1257 | } 1258 | last = tokens.shift(); 1259 | } 1260 | } 1261 | } 1262 | 1263 | function consumeUntil(tokens, match) { 1264 | var result = ""; 1265 | while (tokens.length > 0 && !match.test(tokens[0])) { 1266 | result += tokens.shift(); 1267 | } 1268 | return result; 1269 | } 1270 | 1271 | function consumeCSSSelector(tokens) { 1272 | var result; 1273 | if (tokens.length > 0 && COMBINED_SELECTOR_START.test(tokens[0])) { 1274 | tokens.shift(); 1275 | result = consumeUntil(tokens, COMBINED_SELECTOR_END).trim(); 1276 | tokens.shift(); 1277 | } else { 1278 | result = consumeUntil(tokens, WHITESPACE_OR_COMMA); 1279 | } 1280 | return result; 1281 | } 1282 | 1283 | var INPUT_SELECTOR = 'input, textarea, select'; 1284 | 1285 | /** 1286 | * @param {HTMLElement} elt 1287 | * @param {string} explicitTrigger 1288 | * @param {cache} cache for trigger specs 1289 | * @returns {import("./htmx").HtmxTriggerSpecification[]} 1290 | */ 1291 | function parseAndCacheTrigger(elt, explicitTrigger, cache) { 1292 | var triggerSpecs = []; 1293 | var tokens = tokenizeString(explicitTrigger); 1294 | do { 1295 | consumeUntil(tokens, NOT_WHITESPACE); 1296 | var initialLength = tokens.length; 1297 | var trigger = consumeUntil(tokens, /[,\[\s]/); 1298 | if (trigger !== "") { 1299 | if (trigger === "every") { 1300 | var every = {trigger: 'every'}; 1301 | consumeUntil(tokens, NOT_WHITESPACE); 1302 | every.pollInterval = parseInterval(consumeUntil(tokens, /[,\[\s]/)); 1303 | consumeUntil(tokens, NOT_WHITESPACE); 1304 | var eventFilter = maybeGenerateConditional(elt, tokens, "event"); 1305 | if (eventFilter) { 1306 | every.eventFilter = eventFilter; 1307 | } 1308 | triggerSpecs.push(every); 1309 | } else if (trigger.indexOf("sse:") === 0) { 1310 | triggerSpecs.push({trigger: 'sse', sseEvent: trigger.substr(4)}); 1311 | } else { 1312 | var triggerSpec = {trigger: trigger}; 1313 | var eventFilter = maybeGenerateConditional(elt, tokens, "event"); 1314 | if (eventFilter) { 1315 | triggerSpec.eventFilter = eventFilter; 1316 | } 1317 | while (tokens.length > 0 && tokens[0] !== ",") { 1318 | consumeUntil(tokens, NOT_WHITESPACE) 1319 | var token = tokens.shift(); 1320 | if (token === "changed") { 1321 | triggerSpec.changed = true; 1322 | } else if (token === "once") { 1323 | triggerSpec.once = true; 1324 | } else if (token === "consume") { 1325 | triggerSpec.consume = true; 1326 | } else if (token === "delay" && tokens[0] === ":") { 1327 | tokens.shift(); 1328 | triggerSpec.delay = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); 1329 | } else if (token === "from" && tokens[0] === ":") { 1330 | tokens.shift(); 1331 | if (COMBINED_SELECTOR_START.test(tokens[0])) { 1332 | var from_arg = consumeCSSSelector(tokens); 1333 | } else { 1334 | var from_arg = consumeUntil(tokens, WHITESPACE_OR_COMMA); 1335 | if (from_arg === "closest" || from_arg === "find" || from_arg === "next" || from_arg === "previous") { 1336 | tokens.shift(); 1337 | var selector = consumeCSSSelector(tokens); 1338 | // `next` and `previous` allow a selector-less syntax 1339 | if (selector.length > 0) { 1340 | from_arg += " " + selector; 1341 | } 1342 | } 1343 | } 1344 | triggerSpec.from = from_arg; 1345 | } else if (token === "target" && tokens[0] === ":") { 1346 | tokens.shift(); 1347 | triggerSpec.target = consumeCSSSelector(tokens); 1348 | } else if (token === "throttle" && tokens[0] === ":") { 1349 | tokens.shift(); 1350 | triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)); 1351 | } else if (token === "queue" && tokens[0] === ":") { 1352 | tokens.shift(); 1353 | triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA); 1354 | } else if (token === "root" && tokens[0] === ":") { 1355 | tokens.shift(); 1356 | triggerSpec[token] = consumeCSSSelector(tokens); 1357 | } else if (token === "threshold" && tokens[0] === ":") { 1358 | tokens.shift(); 1359 | triggerSpec[token] = consumeUntil(tokens, WHITESPACE_OR_COMMA); 1360 | } else { 1361 | triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); 1362 | } 1363 | } 1364 | triggerSpecs.push(triggerSpec); 1365 | } 1366 | } 1367 | if (tokens.length === initialLength) { 1368 | triggerErrorEvent(elt, "htmx:syntax:error", {token:tokens.shift()}); 1369 | } 1370 | consumeUntil(tokens, NOT_WHITESPACE); 1371 | } while (tokens[0] === "," && tokens.shift()) 1372 | if (cache) { 1373 | cache[explicitTrigger] = triggerSpecs 1374 | } 1375 | return triggerSpecs 1376 | } 1377 | 1378 | /** 1379 | * @param {HTMLElement} elt 1380 | * @returns {import("./htmx").HtmxTriggerSpecification[]} 1381 | */ 1382 | function getTriggerSpecs(elt) { 1383 | var explicitTrigger = getAttributeValue(elt, 'hx-trigger'); 1384 | var triggerSpecs = []; 1385 | if (explicitTrigger) { 1386 | var cache = htmx.config.triggerSpecsCache 1387 | triggerSpecs = (cache && cache[explicitTrigger]) || parseAndCacheTrigger(elt, explicitTrigger, cache) 1388 | } 1389 | 1390 | if (triggerSpecs.length > 0) { 1391 | return triggerSpecs; 1392 | } else if (matches(elt, 'form')) { 1393 | return [{trigger: 'submit'}]; 1394 | } else if (matches(elt, 'input[type="button"], input[type="submit"]')){ 1395 | return [{trigger: 'click'}]; 1396 | } else if (matches(elt, INPUT_SELECTOR)) { 1397 | return [{trigger: 'change'}]; 1398 | } else { 1399 | return [{trigger: 'click'}]; 1400 | } 1401 | } 1402 | 1403 | function cancelPolling(elt) { 1404 | getInternalData(elt).cancelled = true; 1405 | } 1406 | 1407 | function processPolling(elt, handler, spec) { 1408 | var nodeData = getInternalData(elt); 1409 | nodeData.timeout = setTimeout(function () { 1410 | if (bodyContains(elt) && nodeData.cancelled !== true) { 1411 | if (!maybeFilterEvent(spec, elt, makeEvent('hx:poll:trigger', { 1412 | triggerSpec: spec, 1413 | target: elt 1414 | }))) { 1415 | handler(elt); 1416 | } 1417 | processPolling(elt, handler, spec); 1418 | } 1419 | }, spec.pollInterval); 1420 | } 1421 | 1422 | function isLocalLink(elt) { 1423 | return location.hostname === elt.hostname && 1424 | getRawAttribute(elt,'href') && 1425 | getRawAttribute(elt,'href').indexOf("#") !== 0; 1426 | } 1427 | 1428 | function boostElement(elt, nodeData, triggerSpecs) { 1429 | if ((elt.tagName === "A" && isLocalLink(elt) && (elt.target === "" || elt.target === "_self")) || elt.tagName === "FORM") { 1430 | nodeData.boosted = true; 1431 | var verb, path; 1432 | if (elt.tagName === "A") { 1433 | verb = "get"; 1434 | path = getRawAttribute(elt, 'href') 1435 | } else { 1436 | var rawAttribute = getRawAttribute(elt, "method"); 1437 | verb = rawAttribute ? rawAttribute.toLowerCase() : "get"; 1438 | if (verb === "get") { 1439 | } 1440 | path = getRawAttribute(elt, 'action'); 1441 | } 1442 | triggerSpecs.forEach(function(triggerSpec) { 1443 | addEventListener(elt, function(elt, evt) { 1444 | if (closest(elt, htmx.config.disableSelector)) { 1445 | cleanUpElement(elt) 1446 | return 1447 | } 1448 | issueAjaxRequest(verb, path, elt, evt) 1449 | }, nodeData, triggerSpec, true); 1450 | }); 1451 | } 1452 | } 1453 | 1454 | /** 1455 | * 1456 | * @param {Event} evt 1457 | * @param {HTMLElement} elt 1458 | * @returns 1459 | */ 1460 | function shouldCancel(evt, elt) { 1461 | if (evt.type === "submit" || evt.type === "click") { 1462 | if (elt.tagName === "FORM") { 1463 | return true; 1464 | } 1465 | if (matches(elt, 'input[type="submit"], button') && closest(elt, 'form') !== null) { 1466 | return true; 1467 | } 1468 | if (elt.tagName === "A" && elt.href && 1469 | (elt.getAttribute('href') === '#' || elt.getAttribute('href').indexOf("#") !== 0)) { 1470 | return true; 1471 | } 1472 | } 1473 | return false; 1474 | } 1475 | 1476 | function ignoreBoostedAnchorCtrlClick(elt, evt) { 1477 | return getInternalData(elt).boosted && elt.tagName === "A" && evt.type === "click" && (evt.ctrlKey || evt.metaKey); 1478 | } 1479 | 1480 | function maybeFilterEvent(triggerSpec, elt, evt) { 1481 | var eventFilter = triggerSpec.eventFilter; 1482 | if(eventFilter){ 1483 | try { 1484 | return eventFilter.call(elt, evt) !== true; 1485 | } catch(e) { 1486 | triggerErrorEvent(getDocument().body, "htmx:eventFilter:error", {error: e, source:eventFilter.source}); 1487 | return true; 1488 | } 1489 | } 1490 | return false; 1491 | } 1492 | 1493 | function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) { 1494 | var elementData = getInternalData(elt); 1495 | var eltsToListenOn; 1496 | if (triggerSpec.from) { 1497 | eltsToListenOn = querySelectorAllExt(elt, triggerSpec.from); 1498 | } else { 1499 | eltsToListenOn = [elt]; 1500 | } 1501 | // store the initial values of the elements, so we can tell if they change 1502 | if (triggerSpec.changed) { 1503 | eltsToListenOn.forEach(function (eltToListenOn) { 1504 | var eltToListenOnData = getInternalData(eltToListenOn); 1505 | eltToListenOnData.lastValue = eltToListenOn.value; 1506 | }) 1507 | } 1508 | forEach(eltsToListenOn, function (eltToListenOn) { 1509 | var eventListener = function (evt) { 1510 | if (!bodyContains(elt)) { 1511 | eltToListenOn.removeEventListener(triggerSpec.trigger, eventListener); 1512 | return; 1513 | } 1514 | if (ignoreBoostedAnchorCtrlClick(elt, evt)) { 1515 | return; 1516 | } 1517 | if (explicitCancel || shouldCancel(evt, elt)) { 1518 | evt.preventDefault(); 1519 | } 1520 | if (maybeFilterEvent(triggerSpec, elt, evt)) { 1521 | return; 1522 | } 1523 | var eventData = getInternalData(evt); 1524 | eventData.triggerSpec = triggerSpec; 1525 | if (eventData.handledFor == null) { 1526 | eventData.handledFor = []; 1527 | } 1528 | if (eventData.handledFor.indexOf(elt) < 0) { 1529 | eventData.handledFor.push(elt); 1530 | if (triggerSpec.consume) { 1531 | evt.stopPropagation(); 1532 | } 1533 | if (triggerSpec.target && evt.target) { 1534 | if (!matches(evt.target, triggerSpec.target)) { 1535 | return; 1536 | } 1537 | } 1538 | if (triggerSpec.once) { 1539 | if (elementData.triggeredOnce) { 1540 | return; 1541 | } else { 1542 | elementData.triggeredOnce = true; 1543 | } 1544 | } 1545 | if (triggerSpec.changed) { 1546 | var eltToListenOnData = getInternalData(eltToListenOn) 1547 | if (eltToListenOnData.lastValue === eltToListenOn.value) { 1548 | return; 1549 | } 1550 | eltToListenOnData.lastValue = eltToListenOn.value 1551 | } 1552 | if (elementData.delayed) { 1553 | clearTimeout(elementData.delayed); 1554 | } 1555 | if (elementData.throttle) { 1556 | return; 1557 | } 1558 | 1559 | if (triggerSpec.throttle > 0) { 1560 | if (!elementData.throttle) { 1561 | handler(elt, evt); 1562 | elementData.throttle = setTimeout(function () { 1563 | elementData.throttle = null; 1564 | }, triggerSpec.throttle); 1565 | } 1566 | } else if (triggerSpec.delay > 0) { 1567 | elementData.delayed = setTimeout(function() { handler(elt, evt) }, triggerSpec.delay); 1568 | } else { 1569 | triggerEvent(elt, 'htmx:trigger') 1570 | handler(elt, evt); 1571 | } 1572 | } 1573 | }; 1574 | if (nodeData.listenerInfos == null) { 1575 | nodeData.listenerInfos = []; 1576 | } 1577 | nodeData.listenerInfos.push({ 1578 | trigger: triggerSpec.trigger, 1579 | listener: eventListener, 1580 | on: eltToListenOn 1581 | }) 1582 | eltToListenOn.addEventListener(triggerSpec.trigger, eventListener); 1583 | }); 1584 | } 1585 | 1586 | var windowIsScrolling = false // used by initScrollHandler 1587 | var scrollHandler = null; 1588 | function initScrollHandler() { 1589 | if (!scrollHandler) { 1590 | scrollHandler = function() { 1591 | windowIsScrolling = true 1592 | }; 1593 | window.addEventListener("scroll", scrollHandler) 1594 | setInterval(function() { 1595 | if (windowIsScrolling) { 1596 | windowIsScrolling = false; 1597 | forEach(getDocument().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"), function (elt) { 1598 | maybeReveal(elt); 1599 | }) 1600 | } 1601 | }, 200); 1602 | } 1603 | } 1604 | 1605 | function maybeReveal(elt) { 1606 | if (!hasAttribute(elt,'data-hx-revealed') && isScrolledIntoView(elt)) { 1607 | elt.setAttribute('data-hx-revealed', 'true'); 1608 | var nodeData = getInternalData(elt); 1609 | if (nodeData.initHash) { 1610 | triggerEvent(elt, 'revealed'); 1611 | } else { 1612 | // if the node isn't initialized, wait for it before triggering the request 1613 | elt.addEventListener("htmx:afterProcessNode", function(evt) { triggerEvent(elt, 'revealed') }, {once: true}); 1614 | } 1615 | } 1616 | } 1617 | 1618 | //==================================================================== 1619 | // Web Sockets 1620 | //==================================================================== 1621 | 1622 | function processWebSocketInfo(elt, nodeData, info) { 1623 | var values = splitOnWhitespace(info); 1624 | for (var i = 0; i < values.length; i++) { 1625 | var value = values[i].split(/:(.+)/); 1626 | if (value[0] === "connect") { 1627 | ensureWebSocket(elt, value[1], 0); 1628 | } 1629 | if (value[0] === "send") { 1630 | processWebSocketSend(elt); 1631 | } 1632 | } 1633 | } 1634 | 1635 | function ensureWebSocket(elt, wssSource, retryCount) { 1636 | if (!bodyContains(elt)) { 1637 | return; // stop ensuring websocket connection when socket bearing element ceases to exist 1638 | } 1639 | 1640 | if (wssSource.indexOf("/") == 0) { // complete absolute paths only 1641 | var base_part = location.hostname + (location.port ? ':'+location.port: ''); 1642 | if (location.protocol == 'https:') { 1643 | wssSource = "wss://" + base_part + wssSource; 1644 | } else if (location.protocol == 'http:') { 1645 | wssSource = "ws://" + base_part + wssSource; 1646 | } 1647 | } 1648 | var socket = htmx.createWebSocket(wssSource); 1649 | socket.onerror = function (e) { 1650 | triggerErrorEvent(elt, "htmx:wsError", {error:e, socket:socket}); 1651 | maybeCloseWebSocketSource(elt); 1652 | }; 1653 | 1654 | socket.onclose = function (e) { 1655 | if ([1006, 1012, 1013].indexOf(e.code) >= 0) { // Abnormal Closure/Service Restart/Try Again Later 1656 | var delay = getWebSocketReconnectDelay(retryCount); 1657 | setTimeout(function() { 1658 | ensureWebSocket(elt, wssSource, retryCount+1); // creates a websocket with a new timeout 1659 | }, delay); 1660 | } 1661 | }; 1662 | socket.onopen = function (e) { 1663 | retryCount = 0; 1664 | } 1665 | 1666 | getInternalData(elt).webSocket = socket; 1667 | socket.addEventListener('message', function (event) { 1668 | if (maybeCloseWebSocketSource(elt)) { 1669 | return; 1670 | } 1671 | 1672 | var response = event.data; 1673 | withExtensions(elt, function(extension){ 1674 | response = extension.transformResponse(response, null, elt); 1675 | }); 1676 | 1677 | var settleInfo = makeSettleInfo(elt); 1678 | var fragment = makeFragment(response); 1679 | var children = toArray(fragment.children); 1680 | for (var i = 0; i < children.length; i++) { 1681 | var child = children[i]; 1682 | oobSwap(getAttributeValue(child, "hx-swap-oob") || "true", child, settleInfo); 1683 | } 1684 | 1685 | settleImmediately(settleInfo.tasks); 1686 | }); 1687 | } 1688 | 1689 | function maybeCloseWebSocketSource(elt) { 1690 | if (!bodyContains(elt)) { 1691 | getInternalData(elt).webSocket.close(); 1692 | return true; 1693 | } 1694 | } 1695 | 1696 | function processWebSocketSend(elt) { 1697 | var webSocketSourceElt = getClosestMatch(elt, function (parent) { 1698 | return getInternalData(parent).webSocket != null; 1699 | }); 1700 | if (webSocketSourceElt) { 1701 | elt.addEventListener(getTriggerSpecs(elt)[0].trigger, function (evt) { 1702 | var webSocket = getInternalData(webSocketSourceElt).webSocket; 1703 | var headers = getHeaders(elt, webSocketSourceElt); 1704 | var results = getInputValues(elt, 'post'); 1705 | var errors = results.errors; 1706 | var rawParameters = results.values; 1707 | var expressionVars = getExpressionVars(elt); 1708 | var allParameters = mergeObjects(rawParameters, expressionVars); 1709 | var filteredParameters = filterValues(allParameters, elt); 1710 | filteredParameters['HEADERS'] = headers; 1711 | if (errors && errors.length > 0) { 1712 | triggerEvent(elt, 'htmx:validation:halted', errors); 1713 | return; 1714 | } 1715 | webSocket.send(JSON.stringify(filteredParameters)); 1716 | if(shouldCancel(evt, elt)){ 1717 | evt.preventDefault(); 1718 | } 1719 | }); 1720 | } else { 1721 | triggerErrorEvent(elt, "htmx:noWebSocketSourceError"); 1722 | } 1723 | } 1724 | 1725 | function getWebSocketReconnectDelay(retryCount) { 1726 | var delay = htmx.config.wsReconnectDelay; 1727 | if (typeof delay === 'function') { 1728 | // @ts-ignore 1729 | return delay(retryCount); 1730 | } 1731 | if (delay === 'full-jitter') { 1732 | var exp = Math.min(retryCount, 6); 1733 | var maxDelay = 1000 * Math.pow(2, exp); 1734 | return maxDelay * Math.random(); 1735 | } 1736 | logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"'); 1737 | } 1738 | 1739 | //==================================================================== 1740 | // Server Sent Events 1741 | //==================================================================== 1742 | 1743 | function processSSEInfo(elt, nodeData, info) { 1744 | var values = splitOnWhitespace(info); 1745 | for (var i = 0; i < values.length; i++) { 1746 | var value = values[i].split(/:(.+)/); 1747 | if (value[0] === "connect") { 1748 | processSSESource(elt, value[1]); 1749 | } 1750 | 1751 | if ((value[0] === "swap")) { 1752 | processSSESwap(elt, value[1]) 1753 | } 1754 | } 1755 | } 1756 | 1757 | function processSSESource(elt, sseSrc) { 1758 | var source = htmx.createEventSource(sseSrc); 1759 | source.onerror = function (e) { 1760 | triggerErrorEvent(elt, "htmx:sseError", {error:e, source:source}); 1761 | maybeCloseSSESource(elt); 1762 | }; 1763 | getInternalData(elt).sseEventSource = source; 1764 | } 1765 | 1766 | function processSSESwap(elt, sseEventName) { 1767 | var sseSourceElt = getClosestMatch(elt, hasEventSource); 1768 | if (sseSourceElt) { 1769 | var sseEventSource = getInternalData(sseSourceElt).sseEventSource; 1770 | var sseListener = function (event) { 1771 | if (maybeCloseSSESource(sseSourceElt)) { 1772 | return; 1773 | } 1774 | if (!bodyContains(elt)) { 1775 | sseEventSource.removeEventListener(sseEventName, sseListener); 1776 | return; 1777 | } 1778 | 1779 | /////////////////////////// 1780 | // TODO: merge this code with AJAX and WebSockets code in the future. 1781 | 1782 | var response = event.data; 1783 | withExtensions(elt, function(extension){ 1784 | response = extension.transformResponse(response, null, elt); 1785 | }); 1786 | 1787 | var swapSpec = getSwapSpecification(elt) 1788 | var target = getTarget(elt) 1789 | var settleInfo = makeSettleInfo(elt); 1790 | 1791 | selectAndSwap(swapSpec.swapStyle, target, elt, response, settleInfo) 1792 | settleImmediately(settleInfo.tasks) 1793 | triggerEvent(elt, "htmx:sseMessage", event) 1794 | }; 1795 | 1796 | getInternalData(elt).sseListener = sseListener; 1797 | sseEventSource.addEventListener(sseEventName, sseListener); 1798 | } else { 1799 | triggerErrorEvent(elt, "htmx:noSSESourceError"); 1800 | } 1801 | } 1802 | 1803 | function processSSETrigger(elt, handler, sseEventName) { 1804 | var sseSourceElt = getClosestMatch(elt, hasEventSource); 1805 | if (sseSourceElt) { 1806 | var sseEventSource = getInternalData(sseSourceElt).sseEventSource; 1807 | var sseListener = function () { 1808 | if (!maybeCloseSSESource(sseSourceElt)) { 1809 | if (bodyContains(elt)) { 1810 | handler(elt); 1811 | } else { 1812 | sseEventSource.removeEventListener(sseEventName, sseListener); 1813 | } 1814 | } 1815 | }; 1816 | getInternalData(elt).sseListener = sseListener; 1817 | sseEventSource.addEventListener(sseEventName, sseListener); 1818 | } else { 1819 | triggerErrorEvent(elt, "htmx:noSSESourceError"); 1820 | } 1821 | } 1822 | 1823 | function maybeCloseSSESource(elt) { 1824 | if (!bodyContains(elt)) { 1825 | getInternalData(elt).sseEventSource.close(); 1826 | return true; 1827 | } 1828 | } 1829 | 1830 | function hasEventSource(node) { 1831 | return getInternalData(node).sseEventSource != null; 1832 | } 1833 | 1834 | //==================================================================== 1835 | 1836 | function loadImmediately(elt, handler, nodeData, delay) { 1837 | var load = function(){ 1838 | if (!nodeData.loaded) { 1839 | nodeData.loaded = true; 1840 | handler(elt); 1841 | } 1842 | } 1843 | if (delay > 0) { 1844 | setTimeout(load, delay); 1845 | } else { 1846 | load(); 1847 | } 1848 | } 1849 | 1850 | function processVerbs(elt, nodeData, triggerSpecs) { 1851 | var explicitAction = false; 1852 | forEach(VERBS, function (verb) { 1853 | if (hasAttribute(elt,'hx-' + verb)) { 1854 | var path = getAttributeValue(elt, 'hx-' + verb); 1855 | explicitAction = true; 1856 | nodeData.path = path; 1857 | nodeData.verb = verb; 1858 | triggerSpecs.forEach(function(triggerSpec) { 1859 | addTriggerHandler(elt, triggerSpec, nodeData, function (elt, evt) { 1860 | if (closest(elt, htmx.config.disableSelector)) { 1861 | cleanUpElement(elt) 1862 | return 1863 | } 1864 | issueAjaxRequest(verb, path, elt, evt) 1865 | }) 1866 | }); 1867 | } 1868 | }); 1869 | return explicitAction; 1870 | } 1871 | 1872 | function addTriggerHandler(elt, triggerSpec, nodeData, handler) { 1873 | if (triggerSpec.sseEvent) { 1874 | processSSETrigger(elt, handler, triggerSpec.sseEvent); 1875 | } else if (triggerSpec.trigger === "revealed") { 1876 | initScrollHandler(); 1877 | addEventListener(elt, handler, nodeData, triggerSpec); 1878 | maybeReveal(elt); 1879 | } else if (triggerSpec.trigger === "intersect") { 1880 | var observerOptions = {}; 1881 | if (triggerSpec.root) { 1882 | observerOptions.root = querySelectorExt(elt, triggerSpec.root) 1883 | } 1884 | if (triggerSpec.threshold) { 1885 | observerOptions.threshold = parseFloat(triggerSpec.threshold); 1886 | } 1887 | var observer = new IntersectionObserver(function (entries) { 1888 | for (var i = 0; i < entries.length; i++) { 1889 | var entry = entries[i]; 1890 | if (entry.isIntersecting) { 1891 | triggerEvent(elt, "intersect"); 1892 | break; 1893 | } 1894 | } 1895 | }, observerOptions); 1896 | observer.observe(elt); 1897 | addEventListener(elt, handler, nodeData, triggerSpec); 1898 | } else if (triggerSpec.trigger === "load") { 1899 | if (!maybeFilterEvent(triggerSpec, elt, makeEvent("load", {elt: elt}))) { 1900 | loadImmediately(elt, handler, nodeData, triggerSpec.delay); 1901 | } 1902 | } else if (triggerSpec.pollInterval > 0) { 1903 | nodeData.polling = true; 1904 | processPolling(elt, handler, triggerSpec); 1905 | } else { 1906 | addEventListener(elt, handler, nodeData, triggerSpec); 1907 | } 1908 | } 1909 | 1910 | function evalScript(script) { 1911 | if (!script.htmxExecuted && htmx.config.allowScriptTags && 1912 | (script.type === "text/javascript" || script.type === "module" || script.type === "") ) { 1913 | var newScript = getDocument().createElement("script"); 1914 | forEach(script.attributes, function (attr) { 1915 | newScript.setAttribute(attr.name, attr.value); 1916 | }); 1917 | newScript.textContent = script.textContent; 1918 | newScript.async = false; 1919 | if (htmx.config.inlineScriptNonce) { 1920 | newScript.nonce = htmx.config.inlineScriptNonce; 1921 | } 1922 | var parent = script.parentElement; 1923 | 1924 | try { 1925 | parent.insertBefore(newScript, script); 1926 | } catch (e) { 1927 | logError(e); 1928 | } finally { 1929 | // remove old script element, but only if it is still in DOM 1930 | if (script.parentElement) { 1931 | script.parentElement.removeChild(script); 1932 | } 1933 | } 1934 | } 1935 | } 1936 | 1937 | function processScripts(elt) { 1938 | if (matches(elt, "script")) { 1939 | evalScript(elt); 1940 | } 1941 | forEach(findAll(elt, "script"), function (script) { 1942 | evalScript(script); 1943 | }); 1944 | } 1945 | 1946 | function shouldProcessHxOn(elt) { 1947 | var attributes = elt.attributes 1948 | if (!attributes) { 1949 | return false 1950 | } 1951 | for (var j = 0; j < attributes.length; j++) { 1952 | var attrName = attributes[j].name 1953 | if (startsWith(attrName, "hx-on:") || startsWith(attrName, "data-hx-on:") || 1954 | startsWith(attrName, "hx-on-") || startsWith(attrName, "data-hx-on-")) { 1955 | return true 1956 | } 1957 | } 1958 | return false 1959 | } 1960 | 1961 | function findHxOnWildcardElements(elt) { 1962 | var node = null 1963 | var elements = [] 1964 | 1965 | if (shouldProcessHxOn(elt)) { 1966 | elements.push(elt) 1967 | } 1968 | 1969 | if (document.evaluate) { 1970 | var iter = document.evaluate('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or' + 1971 | ' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]', elt) 1972 | while (node = iter.iterateNext()) elements.push(node) 1973 | } else if (typeof elt.getElementsByTagName === "function") { 1974 | var allElements = elt.getElementsByTagName("*") 1975 | for (var i = 0; i < allElements.length; i++) { 1976 | if (shouldProcessHxOn(allElements[i])) { 1977 | elements.push(allElements[i]) 1978 | } 1979 | } 1980 | } 1981 | 1982 | return elements 1983 | } 1984 | 1985 | function findElementsToProcess(elt) { 1986 | if (elt.querySelectorAll) { 1987 | var boostedSelector = ", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]"; 1988 | var results = elt.querySelectorAll(VERB_SELECTOR + boostedSelector + ", form, [type='submit'], [hx-sse], [data-hx-sse], [hx-ws]," + 1989 | " [data-hx-ws], [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger], [hx-on], [data-hx-on]"); 1990 | return results; 1991 | } else { 1992 | return []; 1993 | } 1994 | } 1995 | 1996 | // Handle submit buttons/inputs that have the form attribute set 1997 | // see https://developer.mozilla.org/docs/Web/HTML/Element/button 1998 | function maybeSetLastButtonClicked(evt) { 1999 | var elt = closest(evt.target, "button, input[type='submit']"); 2000 | var internalData = getRelatedFormData(evt) 2001 | if (internalData) { 2002 | internalData.lastButtonClicked = elt; 2003 | } 2004 | }; 2005 | function maybeUnsetLastButtonClicked(evt){ 2006 | var internalData = getRelatedFormData(evt) 2007 | if (internalData) { 2008 | internalData.lastButtonClicked = null; 2009 | } 2010 | } 2011 | function getRelatedFormData(evt) { 2012 | var elt = closest(evt.target, "button, input[type='submit']"); 2013 | if (!elt) { 2014 | return; 2015 | } 2016 | var form = resolveTarget('#' + getRawAttribute(elt, 'form')) || closest(elt, 'form'); 2017 | if (!form) { 2018 | return; 2019 | } 2020 | return getInternalData(form); 2021 | } 2022 | function initButtonTracking(elt) { 2023 | // need to handle both click and focus in: 2024 | // focusin - in case someone tabs in to a button and hits the space bar 2025 | // click - on OSX buttons do not focus on click see https://bugs.webkit.org/show_bug.cgi?id=13724 2026 | elt.addEventListener('click', maybeSetLastButtonClicked) 2027 | elt.addEventListener('focusin', maybeSetLastButtonClicked) 2028 | elt.addEventListener('focusout', maybeUnsetLastButtonClicked) 2029 | } 2030 | 2031 | function countCurlies(line) { 2032 | var tokens = tokenizeString(line); 2033 | var netCurlies = 0; 2034 | for (var i = 0; i < tokens.length; i++) { 2035 | const token = tokens[i]; 2036 | if (token === "{") { 2037 | netCurlies++; 2038 | } else if (token === "}") { 2039 | netCurlies--; 2040 | } 2041 | } 2042 | return netCurlies; 2043 | } 2044 | 2045 | function addHxOnEventHandler(elt, eventName, code) { 2046 | var nodeData = getInternalData(elt); 2047 | if (!Array.isArray(nodeData.onHandlers)) { 2048 | nodeData.onHandlers = []; 2049 | } 2050 | var func; 2051 | var listener = function (e) { 2052 | return maybeEval(elt, function() { 2053 | if (!func) { 2054 | func = new Function("event", code); 2055 | } 2056 | func.call(elt, e); 2057 | }); 2058 | }; 2059 | elt.addEventListener(eventName, listener); 2060 | nodeData.onHandlers.push({event:eventName, listener:listener}); 2061 | } 2062 | 2063 | function processHxOn(elt) { 2064 | var hxOnValue = getAttributeValue(elt, 'hx-on'); 2065 | if (hxOnValue) { 2066 | var handlers = {} 2067 | var lines = hxOnValue.split("\n"); 2068 | var currentEvent = null; 2069 | var curlyCount = 0; 2070 | while (lines.length > 0) { 2071 | var line = lines.shift(); 2072 | var match = line.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/); 2073 | if (curlyCount === 0 && match) { 2074 | line.split(":") 2075 | currentEvent = match[1].slice(0, -1); // strip last colon 2076 | handlers[currentEvent] = match[2]; 2077 | } else { 2078 | handlers[currentEvent] += line; 2079 | } 2080 | curlyCount += countCurlies(line); 2081 | } 2082 | 2083 | for (var eventName in handlers) { 2084 | addHxOnEventHandler(elt, eventName, handlers[eventName]); 2085 | } 2086 | } 2087 | } 2088 | 2089 | function processHxOnWildcard(elt) { 2090 | // wipe any previous on handlers so that this function takes precedence 2091 | deInitOnHandlers(elt) 2092 | 2093 | for (var i = 0; i < elt.attributes.length; i++) { 2094 | var name = elt.attributes[i].name 2095 | var value = elt.attributes[i].value 2096 | if (startsWith(name, "hx-on") || startsWith(name, "data-hx-on")) { 2097 | var afterOnPosition = name.indexOf("-on") + 3; 2098 | var nextChar = name.slice(afterOnPosition, afterOnPosition + 1); 2099 | if (nextChar === "-" || nextChar === ":") { 2100 | var eventName = name.slice(afterOnPosition + 1); 2101 | // if the eventName starts with a colon or dash, prepend "htmx" for shorthand support 2102 | if (startsWith(eventName, ":")) { 2103 | eventName = "htmx" + eventName 2104 | } else if (startsWith(eventName, "-")) { 2105 | eventName = "htmx:" + eventName.slice(1); 2106 | } else if (startsWith(eventName, "htmx-")) { 2107 | eventName = "htmx:" + eventName.slice(5); 2108 | } 2109 | 2110 | addHxOnEventHandler(elt, eventName, value) 2111 | } 2112 | } 2113 | } 2114 | } 2115 | 2116 | function initNode(elt) { 2117 | if (closest(elt, htmx.config.disableSelector)) { 2118 | cleanUpElement(elt) 2119 | return; 2120 | } 2121 | var nodeData = getInternalData(elt); 2122 | if (nodeData.initHash !== attributeHash(elt)) { 2123 | // clean up any previously processed info 2124 | deInitNode(elt); 2125 | 2126 | nodeData.initHash = attributeHash(elt); 2127 | 2128 | processHxOn(elt); 2129 | 2130 | triggerEvent(elt, "htmx:beforeProcessNode") 2131 | 2132 | if (elt.value) { 2133 | nodeData.lastValue = elt.value; 2134 | } 2135 | 2136 | var triggerSpecs = getTriggerSpecs(elt); 2137 | var hasExplicitHttpAction = processVerbs(elt, nodeData, triggerSpecs); 2138 | 2139 | if (!hasExplicitHttpAction) { 2140 | if (getClosestAttributeValue(elt, "hx-boost") === "true") { 2141 | boostElement(elt, nodeData, triggerSpecs); 2142 | } else if (hasAttribute(elt, 'hx-trigger')) { 2143 | triggerSpecs.forEach(function (triggerSpec) { 2144 | // For "naked" triggers, don't do anything at all 2145 | addTriggerHandler(elt, triggerSpec, nodeData, function () { 2146 | }) 2147 | }) 2148 | } 2149 | } 2150 | 2151 | // Handle submit buttons/inputs that have the form attribute set 2152 | // see https://developer.mozilla.org/docs/Web/HTML/Element/button 2153 | if (elt.tagName === "FORM" || (getRawAttribute(elt, "type") === "submit" && hasAttribute(elt, "form"))) { 2154 | initButtonTracking(elt) 2155 | } 2156 | 2157 | var sseInfo = getAttributeValue(elt, 'hx-sse'); 2158 | if (sseInfo) { 2159 | processSSEInfo(elt, nodeData, sseInfo); 2160 | } 2161 | 2162 | var wsInfo = getAttributeValue(elt, 'hx-ws'); 2163 | if (wsInfo) { 2164 | processWebSocketInfo(elt, nodeData, wsInfo); 2165 | } 2166 | triggerEvent(elt, "htmx:afterProcessNode"); 2167 | } 2168 | } 2169 | 2170 | function processNode(elt) { 2171 | elt = resolveTarget(elt); 2172 | if (closest(elt, htmx.config.disableSelector)) { 2173 | cleanUpElement(elt) 2174 | return; 2175 | } 2176 | initNode(elt); 2177 | forEach(findElementsToProcess(elt), function(child) { initNode(child) }); 2178 | // Because it happens second, the new way of adding onHandlers superseeds the old one 2179 | // i.e. if there are any hx-on:eventName attributes, the hx-on attribute will be ignored 2180 | forEach(findHxOnWildcardElements(elt), processHxOnWildcard); 2181 | } 2182 | 2183 | //==================================================================== 2184 | // Event/Log Support 2185 | //==================================================================== 2186 | 2187 | function kebabEventName(str) { 2188 | return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); 2189 | } 2190 | 2191 | function makeEvent(eventName, detail) { 2192 | var evt; 2193 | if (window.CustomEvent && typeof window.CustomEvent === 'function') { 2194 | evt = new CustomEvent(eventName, {bubbles: true, cancelable: true, detail: detail}); 2195 | } else { 2196 | evt = getDocument().createEvent('CustomEvent'); 2197 | evt.initCustomEvent(eventName, true, true, detail); 2198 | } 2199 | return evt; 2200 | } 2201 | 2202 | function triggerErrorEvent(elt, eventName, detail) { 2203 | triggerEvent(elt, eventName, mergeObjects({error:eventName}, detail)); 2204 | } 2205 | 2206 | function ignoreEventForLogging(eventName) { 2207 | return eventName === "htmx:afterProcessNode" 2208 | } 2209 | 2210 | /** 2211 | * `withExtensions` locates all active extensions for a provided element, then 2212 | * executes the provided function using each of the active extensions. It should 2213 | * be called internally at every extendable execution point in htmx. 2214 | * 2215 | * @param {HTMLElement} elt 2216 | * @param {(extension:import("./htmx").HtmxExtension) => void} toDo 2217 | * @returns void 2218 | */ 2219 | function withExtensions(elt, toDo) { 2220 | forEach(getExtensions(elt), function(extension){ 2221 | try { 2222 | toDo(extension); 2223 | } catch (e) { 2224 | logError(e); 2225 | } 2226 | }); 2227 | } 2228 | 2229 | function logError(msg) { 2230 | if(console.error) { 2231 | console.error(msg); 2232 | } else if (console.log) { 2233 | console.log("ERROR: ", msg); 2234 | } 2235 | } 2236 | 2237 | function triggerEvent(elt, eventName, detail) { 2238 | elt = resolveTarget(elt); 2239 | if (detail == null) { 2240 | detail = {}; 2241 | } 2242 | detail["elt"] = elt; 2243 | var event = makeEvent(eventName, detail); 2244 | if (htmx.logger && !ignoreEventForLogging(eventName)) { 2245 | htmx.logger(elt, eventName, detail); 2246 | } 2247 | if (detail.error) { 2248 | logError(detail.error); 2249 | triggerEvent(elt, "htmx:error", {errorInfo:detail}) 2250 | } 2251 | var eventResult = elt.dispatchEvent(event); 2252 | var kebabName = kebabEventName(eventName); 2253 | if (eventResult && kebabName !== eventName) { 2254 | var kebabedEvent = makeEvent(kebabName, event.detail); 2255 | eventResult = eventResult && elt.dispatchEvent(kebabedEvent) 2256 | } 2257 | withExtensions(elt, function (extension) { 2258 | eventResult = eventResult && (extension.onEvent(eventName, event) !== false && !event.defaultPrevented) 2259 | }); 2260 | return eventResult; 2261 | } 2262 | 2263 | //==================================================================== 2264 | // History Support 2265 | //==================================================================== 2266 | var currentPathForHistory = location.pathname+location.search; 2267 | 2268 | function getHistoryElement() { 2269 | var historyElt = getDocument().querySelector('[hx-history-elt],[data-hx-history-elt]'); 2270 | return historyElt || getDocument().body; 2271 | } 2272 | 2273 | function saveToHistoryCache(url, content, title, scroll) { 2274 | if (!canAccessLocalStorage()) { 2275 | return; 2276 | } 2277 | 2278 | if (htmx.config.historyCacheSize <= 0) { 2279 | // make sure that an eventually already existing cache is purged 2280 | localStorage.removeItem("htmx-history-cache"); 2281 | return; 2282 | } 2283 | 2284 | url = normalizePath(url); 2285 | 2286 | var historyCache = parseJSON(localStorage.getItem("htmx-history-cache")) || []; 2287 | for (var i = 0; i < historyCache.length; i++) { 2288 | if (historyCache[i].url === url) { 2289 | historyCache.splice(i, 1); 2290 | break; 2291 | } 2292 | } 2293 | var newHistoryItem = {url:url, content: content, title:title, scroll:scroll}; 2294 | triggerEvent(getDocument().body, "htmx:historyItemCreated", {item:newHistoryItem, cache: historyCache}) 2295 | historyCache.push(newHistoryItem) 2296 | while (historyCache.length > htmx.config.historyCacheSize) { 2297 | historyCache.shift(); 2298 | } 2299 | while(historyCache.length > 0){ 2300 | try { 2301 | localStorage.setItem("htmx-history-cache", JSON.stringify(historyCache)); 2302 | break; 2303 | } catch (e) { 2304 | triggerErrorEvent(getDocument().body, "htmx:historyCacheError", {cause:e, cache: historyCache}) 2305 | historyCache.shift(); // shrink the cache and retry 2306 | } 2307 | } 2308 | } 2309 | 2310 | function getCachedHistory(url) { 2311 | if (!canAccessLocalStorage()) { 2312 | return null; 2313 | } 2314 | 2315 | url = normalizePath(url); 2316 | 2317 | var historyCache = parseJSON(localStorage.getItem("htmx-history-cache")) || []; 2318 | for (var i = 0; i < historyCache.length; i++) { 2319 | if (historyCache[i].url === url) { 2320 | return historyCache[i]; 2321 | } 2322 | } 2323 | return null; 2324 | } 2325 | 2326 | function cleanInnerHtmlForHistory(elt) { 2327 | var className = htmx.config.requestClass; 2328 | var clone = elt.cloneNode(true); 2329 | forEach(findAll(clone, "." + className), function(child){ 2330 | removeClassFromElement(child, className); 2331 | }); 2332 | return clone.innerHTML; 2333 | } 2334 | 2335 | function saveCurrentPageToHistory() { 2336 | var elt = getHistoryElement(); 2337 | var path = currentPathForHistory || location.pathname+location.search; 2338 | 2339 | // Allow history snapshot feature to be disabled where hx-history="false" 2340 | // is present *anywhere* in the current document we're about to save, 2341 | // so we can prevent privileged data entering the cache. 2342 | // The page will still be reachable as a history entry, but htmx will fetch it 2343 | // live from the server onpopstate rather than look in the localStorage cache 2344 | var disableHistoryCache 2345 | try { 2346 | disableHistoryCache = getDocument().querySelector('[hx-history="false" i],[data-hx-history="false" i]') 2347 | } catch (e) { 2348 | // IE11: insensitive modifier not supported so fallback to case sensitive selector 2349 | disableHistoryCache = getDocument().querySelector('[hx-history="false"],[data-hx-history="false"]') 2350 | } 2351 | if (!disableHistoryCache) { 2352 | triggerEvent(getDocument().body, "htmx:beforeHistorySave", {path: path, historyElt: elt}); 2353 | saveToHistoryCache(path, cleanInnerHtmlForHistory(elt), getDocument().title, window.scrollY); 2354 | } 2355 | 2356 | if (htmx.config.historyEnabled) history.replaceState({htmx: true}, getDocument().title, window.location.href); 2357 | } 2358 | 2359 | function pushUrlIntoHistory(path) { 2360 | // remove the cache buster parameter, if any 2361 | if (htmx.config.getCacheBusterParam) { 2362 | path = path.replace(/org\.htmx\.cache-buster=[^&]*&?/, '') 2363 | if (endsWith(path, '&') || endsWith(path, "?")) { 2364 | path = path.slice(0, -1); 2365 | } 2366 | } 2367 | if(htmx.config.historyEnabled) { 2368 | history.pushState({htmx:true}, "", path); 2369 | } 2370 | currentPathForHistory = path; 2371 | } 2372 | 2373 | function replaceUrlInHistory(path) { 2374 | if(htmx.config.historyEnabled) history.replaceState({htmx:true}, "", path); 2375 | currentPathForHistory = path; 2376 | } 2377 | 2378 | function settleImmediately(tasks) { 2379 | forEach(tasks, function (task) { 2380 | task.call(); 2381 | }); 2382 | } 2383 | 2384 | function loadHistoryFromServer(path) { 2385 | var request = new XMLHttpRequest(); 2386 | var details = {path: path, xhr:request}; 2387 | triggerEvent(getDocument().body, "htmx:historyCacheMiss", details); 2388 | request.open('GET', path, true); 2389 | request.setRequestHeader("HX-Request", "true"); 2390 | request.setRequestHeader("HX-History-Restore-Request", "true"); 2391 | request.setRequestHeader("HX-Current-URL", getDocument().location.href); 2392 | request.onload = function () { 2393 | if (this.status >= 200 && this.status < 400) { 2394 | triggerEvent(getDocument().body, "htmx:historyCacheMissLoad", details); 2395 | var fragment = makeFragment(this.response); 2396 | // @ts-ignore 2397 | fragment = fragment.querySelector('[hx-history-elt],[data-hx-history-elt]') || fragment; 2398 | var historyElement = getHistoryElement(); 2399 | var settleInfo = makeSettleInfo(historyElement); 2400 | var title = findTitle(this.response); 2401 | if (title) { 2402 | var titleElt = find("title"); 2403 | if (titleElt) { 2404 | titleElt.innerHTML = title; 2405 | } else { 2406 | window.document.title = title; 2407 | } 2408 | } 2409 | // @ts-ignore 2410 | swapInnerHTML(historyElement, fragment, settleInfo) 2411 | settleImmediately(settleInfo.tasks); 2412 | currentPathForHistory = path; 2413 | triggerEvent(getDocument().body, "htmx:historyRestore", {path: path, cacheMiss:true, serverResponse:this.response}); 2414 | } else { 2415 | triggerErrorEvent(getDocument().body, "htmx:historyCacheMissLoadError", details); 2416 | } 2417 | }; 2418 | request.send(); 2419 | } 2420 | 2421 | function restoreHistory(path) { 2422 | saveCurrentPageToHistory(); 2423 | path = path || location.pathname+location.search; 2424 | var cached = getCachedHistory(path); 2425 | if (cached) { 2426 | var fragment = makeFragment(cached.content); 2427 | var historyElement = getHistoryElement(); 2428 | var settleInfo = makeSettleInfo(historyElement); 2429 | swapInnerHTML(historyElement, fragment, settleInfo) 2430 | settleImmediately(settleInfo.tasks); 2431 | document.title = cached.title; 2432 | setTimeout(function () { 2433 | window.scrollTo(0, cached.scroll); 2434 | }, 0); // next 'tick', so browser has time to render layout 2435 | currentPathForHistory = path; 2436 | triggerEvent(getDocument().body, "htmx:historyRestore", {path:path, item:cached}); 2437 | } else { 2438 | if (htmx.config.refreshOnHistoryMiss) { 2439 | 2440 | // @ts-ignore: optional parameter in reload() function throws error 2441 | window.location.reload(true); 2442 | } else { 2443 | loadHistoryFromServer(path); 2444 | } 2445 | } 2446 | } 2447 | 2448 | function addRequestIndicatorClasses(elt) { 2449 | var indicators = findAttributeTargets(elt, 'hx-indicator'); 2450 | if (indicators == null) { 2451 | indicators = [elt]; 2452 | } 2453 | forEach(indicators, function (ic) { 2454 | var internalData = getInternalData(ic); 2455 | internalData.requestCount = (internalData.requestCount || 0) + 1; 2456 | ic.classList["add"].call(ic.classList, htmx.config.requestClass); 2457 | }); 2458 | return indicators; 2459 | } 2460 | 2461 | function disableElements(elt) { 2462 | var disabledElts = findAttributeTargets(elt, 'hx-disabled-elt'); 2463 | if (disabledElts == null) { 2464 | disabledElts = []; 2465 | } 2466 | forEach(disabledElts, function (disabledElement) { 2467 | var internalData = getInternalData(disabledElement); 2468 | internalData.requestCount = (internalData.requestCount || 0) + 1; 2469 | disabledElement.setAttribute("disabled", ""); 2470 | }); 2471 | return disabledElts; 2472 | } 2473 | 2474 | function removeRequestIndicators(indicators, disabled) { 2475 | forEach(indicators, function (ic) { 2476 | var internalData = getInternalData(ic); 2477 | internalData.requestCount = (internalData.requestCount || 0) - 1; 2478 | if (internalData.requestCount === 0) { 2479 | ic.classList["remove"].call(ic.classList, htmx.config.requestClass); 2480 | } 2481 | }); 2482 | forEach(disabled, function (disabledElement) { 2483 | var internalData = getInternalData(disabledElement); 2484 | internalData.requestCount = (internalData.requestCount || 0) - 1; 2485 | if (internalData.requestCount === 0) { 2486 | disabledElement.removeAttribute('disabled'); 2487 | } 2488 | }); 2489 | } 2490 | 2491 | //==================================================================== 2492 | // Input Value Processing 2493 | //==================================================================== 2494 | 2495 | function haveSeenNode(processed, elt) { 2496 | for (var i = 0; i < processed.length; i++) { 2497 | var node = processed[i]; 2498 | if (node.isSameNode(elt)) { 2499 | return true; 2500 | } 2501 | } 2502 | return false; 2503 | } 2504 | 2505 | function shouldInclude(elt) { 2506 | if(elt.name === "" || elt.name == null || elt.disabled || closest(elt, "fieldset[disabled]")) { 2507 | return false; 2508 | } 2509 | // ignore "submitter" types (see jQuery src/serialize.js) 2510 | if (elt.type === "button" || elt.type === "submit" || elt.tagName === "image" || elt.tagName === "reset" || elt.tagName === "file" ) { 2511 | return false; 2512 | } 2513 | if (elt.type === "checkbox" || elt.type === "radio" ) { 2514 | return elt.checked; 2515 | } 2516 | return true; 2517 | } 2518 | 2519 | function addValueToValues(name, value, values) { 2520 | // This is a little ugly because both the current value of the named value in the form 2521 | // and the new value could be arrays, so we have to handle all four cases :/ 2522 | if (name != null && value != null) { 2523 | var current = values[name]; 2524 | if (current === undefined) { 2525 | values[name] = value; 2526 | } else if (Array.isArray(current)) { 2527 | if (Array.isArray(value)) { 2528 | values[name] = current.concat(value); 2529 | } else { 2530 | current.push(value); 2531 | } 2532 | } else { 2533 | if (Array.isArray(value)) { 2534 | values[name] = [current].concat(value); 2535 | } else { 2536 | values[name] = [current, value]; 2537 | } 2538 | } 2539 | } 2540 | } 2541 | 2542 | function processInputValue(processed, values, errors, elt, validate) { 2543 | if (elt == null || haveSeenNode(processed, elt)) { 2544 | return; 2545 | } else { 2546 | processed.push(elt); 2547 | } 2548 | if (shouldInclude(elt)) { 2549 | var name = getRawAttribute(elt,"name"); 2550 | var value = elt.value; 2551 | if (elt.multiple && elt.tagName === "SELECT") { 2552 | value = toArray(elt.querySelectorAll("option:checked")).map(function (e) { return e.value }); 2553 | } 2554 | // include file inputs 2555 | if (elt.files) { 2556 | value = toArray(elt.files); 2557 | } 2558 | addValueToValues(name, value, values); 2559 | if (validate) { 2560 | validateElement(elt, errors); 2561 | } 2562 | } 2563 | if (matches(elt, 'form')) { 2564 | var inputs = elt.elements; 2565 | forEach(inputs, function(input) { 2566 | processInputValue(processed, values, errors, input, validate); 2567 | }); 2568 | } 2569 | } 2570 | 2571 | function validateElement(element, errors) { 2572 | if (element.willValidate) { 2573 | triggerEvent(element, "htmx:validation:validate") 2574 | if (!element.checkValidity()) { 2575 | errors.push({elt: element, message:element.validationMessage, validity:element.validity}); 2576 | triggerEvent(element, "htmx:validation:failed", {message:element.validationMessage, validity:element.validity}) 2577 | } 2578 | } 2579 | } 2580 | 2581 | /** 2582 | * @param {HTMLElement} elt 2583 | * @param {string} verb 2584 | */ 2585 | function getInputValues(elt, verb) { 2586 | var processed = []; 2587 | var values = {}; 2588 | var formValues = {}; 2589 | var errors = []; 2590 | var internalData = getInternalData(elt); 2591 | if (internalData.lastButtonClicked && !bodyContains(internalData.lastButtonClicked)) { 2592 | internalData.lastButtonClicked = null 2593 | } 2594 | 2595 | // only validate when form is directly submitted and novalidate or formnovalidate are not set 2596 | // or if the element has an explicit hx-validate="true" on it 2597 | var validate = (matches(elt, 'form') && elt.noValidate !== true) || getAttributeValue(elt, "hx-validate") === "true"; 2598 | if (internalData.lastButtonClicked) { 2599 | validate = validate && internalData.lastButtonClicked.formNoValidate !== true; 2600 | } 2601 | 2602 | // for a non-GET include the closest form 2603 | if (verb !== 'get') { 2604 | processInputValue(processed, formValues, errors, closest(elt, 'form'), validate); 2605 | } 2606 | 2607 | // include the element itself 2608 | processInputValue(processed, values, errors, elt, validate); 2609 | 2610 | // if a button or submit was clicked last, include its value 2611 | if (internalData.lastButtonClicked || elt.tagName === "BUTTON" || 2612 | (elt.tagName === "INPUT" && getRawAttribute(elt, "type") === "submit")) { 2613 | var button = internalData.lastButtonClicked || elt 2614 | var name = getRawAttribute(button, "name") 2615 | addValueToValues(name, button.value, formValues) 2616 | } 2617 | 2618 | // include any explicit includes 2619 | var includes = findAttributeTargets(elt, "hx-include"); 2620 | forEach(includes, function(node) { 2621 | processInputValue(processed, values, errors, node, validate); 2622 | // if a non-form is included, include any input values within it 2623 | if (!matches(node, 'form')) { 2624 | forEach(node.querySelectorAll(INPUT_SELECTOR), function (descendant) { 2625 | processInputValue(processed, values, errors, descendant, validate); 2626 | }) 2627 | } 2628 | }); 2629 | 2630 | // form values take precedence, overriding the regular values 2631 | values = mergeObjects(values, formValues); 2632 | 2633 | return {errors:errors, values:values}; 2634 | } 2635 | 2636 | function appendParam(returnStr, name, realValue) { 2637 | if (returnStr !== "") { 2638 | returnStr += "&"; 2639 | } 2640 | if (String(realValue) === "[object Object]") { 2641 | realValue = JSON.stringify(realValue); 2642 | } 2643 | var s = encodeURIComponent(realValue); 2644 | returnStr += encodeURIComponent(name) + "=" + s; 2645 | return returnStr; 2646 | } 2647 | 2648 | function urlEncode(values) { 2649 | var returnStr = ""; 2650 | for (var name in values) { 2651 | if (values.hasOwnProperty(name)) { 2652 | var value = values[name]; 2653 | if (Array.isArray(value)) { 2654 | forEach(value, function(v) { 2655 | returnStr = appendParam(returnStr, name, v); 2656 | }); 2657 | } else { 2658 | returnStr = appendParam(returnStr, name, value); 2659 | } 2660 | } 2661 | } 2662 | return returnStr; 2663 | } 2664 | 2665 | function makeFormData(values) { 2666 | var formData = new FormData(); 2667 | for (var name in values) { 2668 | if (values.hasOwnProperty(name)) { 2669 | var value = values[name]; 2670 | if (Array.isArray(value)) { 2671 | forEach(value, function(v) { 2672 | formData.append(name, v); 2673 | }); 2674 | } else { 2675 | formData.append(name, value); 2676 | } 2677 | } 2678 | } 2679 | return formData; 2680 | } 2681 | 2682 | //==================================================================== 2683 | // Ajax 2684 | //==================================================================== 2685 | 2686 | /** 2687 | * @param {HTMLElement} elt 2688 | * @param {HTMLElement} target 2689 | * @param {string} prompt 2690 | * @returns {Object} // TODO: Define/Improve HtmxHeaderSpecification 2691 | */ 2692 | function getHeaders(elt, target, prompt) { 2693 | var headers = { 2694 | "HX-Request" : "true", 2695 | "HX-Trigger" : getRawAttribute(elt, "id"), 2696 | "HX-Trigger-Name" : getRawAttribute(elt, "name"), 2697 | "HX-Target" : getAttributeValue(target, "id"), 2698 | "HX-Current-URL" : getDocument().location.href, 2699 | } 2700 | getValuesForElement(elt, "hx-headers", false, headers) 2701 | if (prompt !== undefined) { 2702 | headers["HX-Prompt"] = prompt; 2703 | } 2704 | if (getInternalData(elt).boosted) { 2705 | headers["HX-Boosted"] = "true"; 2706 | } 2707 | return headers; 2708 | } 2709 | 2710 | /** 2711 | * filterValues takes an object containing form input values 2712 | * and returns a new object that only contains keys that are 2713 | * specified by the closest "hx-params" attribute 2714 | * @param {Object} inputValues 2715 | * @param {HTMLElement} elt 2716 | * @returns {Object} 2717 | */ 2718 | function filterValues(inputValues, elt) { 2719 | var paramsValue = getClosestAttributeValue(elt, "hx-params"); 2720 | if (paramsValue) { 2721 | if (paramsValue === "none") { 2722 | return {}; 2723 | } else if (paramsValue === "*") { 2724 | return inputValues; 2725 | } else if(paramsValue.indexOf("not ") === 0) { 2726 | forEach(paramsValue.substr(4).split(","), function (name) { 2727 | name = name.trim(); 2728 | delete inputValues[name]; 2729 | }); 2730 | return inputValues; 2731 | } else { 2732 | var newValues = {} 2733 | forEach(paramsValue.split(","), function (name) { 2734 | name = name.trim(); 2735 | newValues[name] = inputValues[name]; 2736 | }); 2737 | return newValues; 2738 | } 2739 | } else { 2740 | return inputValues; 2741 | } 2742 | } 2743 | 2744 | function isAnchorLink(elt) { 2745 | return getRawAttribute(elt, 'href') && getRawAttribute(elt, 'href').indexOf("#") >=0 2746 | } 2747 | 2748 | /** 2749 | * 2750 | * @param {HTMLElement} elt 2751 | * @param {string} swapInfoOverride 2752 | * @returns {import("./htmx").HtmxSwapSpecification} 2753 | */ 2754 | function getSwapSpecification(elt, swapInfoOverride) { 2755 | var swapInfo = swapInfoOverride ? swapInfoOverride : getClosestAttributeValue(elt, "hx-swap"); 2756 | var swapSpec = { 2757 | "swapStyle" : getInternalData(elt).boosted ? 'innerHTML' : htmx.config.defaultSwapStyle, 2758 | "swapDelay" : htmx.config.defaultSwapDelay, 2759 | "settleDelay" : htmx.config.defaultSettleDelay 2760 | } 2761 | if (htmx.config.scrollIntoViewOnBoost && getInternalData(elt).boosted && !isAnchorLink(elt)) { 2762 | swapSpec["show"] = "top" 2763 | } 2764 | if (swapInfo) { 2765 | var split = splitOnWhitespace(swapInfo); 2766 | if (split.length > 0) { 2767 | for (var i = 0; i < split.length; i++) { 2768 | var value = split[i]; 2769 | if (value.indexOf("swap:") === 0) { 2770 | swapSpec["swapDelay"] = parseInterval(value.substr(5)); 2771 | } else if (value.indexOf("settle:") === 0) { 2772 | swapSpec["settleDelay"] = parseInterval(value.substr(7)); 2773 | } else if (value.indexOf("transition:") === 0) { 2774 | swapSpec["transition"] = value.substr(11) === "true"; 2775 | } else if (value.indexOf("ignoreTitle:") === 0) { 2776 | swapSpec["ignoreTitle"] = value.substr(12) === "true"; 2777 | } else if (value.indexOf("scroll:") === 0) { 2778 | var scrollSpec = value.substr(7); 2779 | var splitSpec = scrollSpec.split(":"); 2780 | var scrollVal = splitSpec.pop(); 2781 | var selectorVal = splitSpec.length > 0 ? splitSpec.join(":") : null; 2782 | swapSpec["scroll"] = scrollVal; 2783 | swapSpec["scrollTarget"] = selectorVal; 2784 | } else if (value.indexOf("show:") === 0) { 2785 | var showSpec = value.substr(5); 2786 | var splitSpec = showSpec.split(":"); 2787 | var showVal = splitSpec.pop(); 2788 | var selectorVal = splitSpec.length > 0 ? splitSpec.join(":") : null; 2789 | swapSpec["show"] = showVal; 2790 | swapSpec["showTarget"] = selectorVal; 2791 | } else if (value.indexOf("focus-scroll:") === 0) { 2792 | var focusScrollVal = value.substr("focus-scroll:".length); 2793 | swapSpec["focusScroll"] = focusScrollVal == "true"; 2794 | } else if (i == 0) { 2795 | swapSpec["swapStyle"] = value; 2796 | } else { 2797 | logError('Unknown modifier in hx-swap: ' + value); 2798 | } 2799 | } 2800 | } 2801 | } 2802 | return swapSpec; 2803 | } 2804 | 2805 | function usesFormData(elt) { 2806 | return getClosestAttributeValue(elt, "hx-encoding") === "multipart/form-data" || 2807 | (matches(elt, "form") && getRawAttribute(elt, 'enctype') === "multipart/form-data"); 2808 | } 2809 | 2810 | function encodeParamsForBody(xhr, elt, filteredParameters) { 2811 | var encodedParameters = null; 2812 | withExtensions(elt, function (extension) { 2813 | if (encodedParameters == null) { 2814 | encodedParameters = extension.encodeParameters(xhr, filteredParameters, elt); 2815 | } 2816 | }); 2817 | if (encodedParameters != null) { 2818 | return encodedParameters; 2819 | } else { 2820 | if (usesFormData(elt)) { 2821 | return makeFormData(filteredParameters); 2822 | } else { 2823 | return urlEncode(filteredParameters); 2824 | } 2825 | } 2826 | } 2827 | 2828 | /** 2829 | * 2830 | * @param {Element} target 2831 | * @returns {import("./htmx").HtmxSettleInfo} 2832 | */ 2833 | function makeSettleInfo(target) { 2834 | return {tasks: [], elts: [target]}; 2835 | } 2836 | 2837 | function updateScrollState(content, swapSpec) { 2838 | var first = content[0]; 2839 | var last = content[content.length - 1]; 2840 | if (swapSpec.scroll) { 2841 | var target = null; 2842 | if (swapSpec.scrollTarget) { 2843 | target = querySelectorExt(first, swapSpec.scrollTarget); 2844 | } 2845 | if (swapSpec.scroll === "top" && (first || target)) { 2846 | target = target || first; 2847 | target.scrollTop = 0; 2848 | } 2849 | if (swapSpec.scroll === "bottom" && (last || target)) { 2850 | target = target || last; 2851 | target.scrollTop = target.scrollHeight; 2852 | } 2853 | } 2854 | if (swapSpec.show) { 2855 | var target = null; 2856 | if (swapSpec.showTarget) { 2857 | var targetStr = swapSpec.showTarget; 2858 | if (swapSpec.showTarget === "window") { 2859 | targetStr = "body"; 2860 | } 2861 | target = querySelectorExt(first, targetStr); 2862 | } 2863 | if (swapSpec.show === "top" && (first || target)) { 2864 | target = target || first; 2865 | target.scrollIntoView({block:'start', behavior: htmx.config.scrollBehavior}); 2866 | } 2867 | if (swapSpec.show === "bottom" && (last || target)) { 2868 | target = target || last; 2869 | target.scrollIntoView({block:'end', behavior: htmx.config.scrollBehavior}); 2870 | } 2871 | } 2872 | } 2873 | 2874 | /** 2875 | * @param {HTMLElement} elt 2876 | * @param {string} attr 2877 | * @param {boolean=} evalAsDefault 2878 | * @param {Object=} values 2879 | * @returns {Object} 2880 | */ 2881 | function getValuesForElement(elt, attr, evalAsDefault, values) { 2882 | if (values == null) { 2883 | values = {}; 2884 | } 2885 | if (elt == null) { 2886 | return values; 2887 | } 2888 | var attributeValue = getAttributeValue(elt, attr); 2889 | if (attributeValue) { 2890 | var str = attributeValue.trim(); 2891 | var evaluateValue = evalAsDefault; 2892 | if (str === "unset") { 2893 | return null; 2894 | } 2895 | if (str.indexOf("javascript:") === 0) { 2896 | str = str.substr(11); 2897 | evaluateValue = true; 2898 | } else if (str.indexOf("js:") === 0) { 2899 | str = str.substr(3); 2900 | evaluateValue = true; 2901 | } 2902 | if (str.indexOf('{') !== 0) { 2903 | str = "{" + str + "}"; 2904 | } 2905 | var varsValues; 2906 | if (evaluateValue) { 2907 | varsValues = maybeEval(elt,function () {return Function("return (" + str + ")")();}, {}); 2908 | } else { 2909 | varsValues = parseJSON(str); 2910 | } 2911 | for (var key in varsValues) { 2912 | if (varsValues.hasOwnProperty(key)) { 2913 | if (values[key] == null) { 2914 | values[key] = varsValues[key]; 2915 | } 2916 | } 2917 | } 2918 | } 2919 | return getValuesForElement(parentElt(elt), attr, evalAsDefault, values); 2920 | } 2921 | 2922 | function maybeEval(elt, toEval, defaultVal) { 2923 | if (htmx.config.allowEval) { 2924 | return toEval(); 2925 | } else { 2926 | triggerErrorEvent(elt, 'htmx:evalDisallowedError'); 2927 | return defaultVal; 2928 | } 2929 | } 2930 | 2931 | /** 2932 | * @param {HTMLElement} elt 2933 | * @param {*} expressionVars 2934 | * @returns 2935 | */ 2936 | function getHXVarsForElement(elt, expressionVars) { 2937 | return getValuesForElement(elt, "hx-vars", true, expressionVars); 2938 | } 2939 | 2940 | /** 2941 | * @param {HTMLElement} elt 2942 | * @param {*} expressionVars 2943 | * @returns 2944 | */ 2945 | function getHXValsForElement(elt, expressionVars) { 2946 | return getValuesForElement(elt, "hx-vals", false, expressionVars); 2947 | } 2948 | 2949 | /** 2950 | * @param {HTMLElement} elt 2951 | * @returns {Object} 2952 | */ 2953 | function getExpressionVars(elt) { 2954 | return mergeObjects(getHXVarsForElement(elt), getHXValsForElement(elt)); 2955 | } 2956 | 2957 | function safelySetHeaderValue(xhr, header, headerValue) { 2958 | if (headerValue !== null) { 2959 | try { 2960 | xhr.setRequestHeader(header, headerValue); 2961 | } catch (e) { 2962 | // On an exception, try to set the header URI encoded instead 2963 | xhr.setRequestHeader(header, encodeURIComponent(headerValue)); 2964 | xhr.setRequestHeader(header + "-URI-AutoEncoded", "true"); 2965 | } 2966 | } 2967 | } 2968 | 2969 | function getPathFromResponse(xhr) { 2970 | // NB: IE11 does not support this stuff 2971 | if (xhr.responseURL && typeof(URL) !== "undefined") { 2972 | try { 2973 | var url = new URL(xhr.responseURL); 2974 | return url.pathname + url.search; 2975 | } catch (e) { 2976 | triggerErrorEvent(getDocument().body, "htmx:badResponseUrl", {url: xhr.responseURL}); 2977 | } 2978 | } 2979 | } 2980 | 2981 | function hasHeader(xhr, regexp) { 2982 | return regexp.test(xhr.getAllResponseHeaders()) 2983 | } 2984 | 2985 | function ajaxHelper(verb, path, context) { 2986 | verb = verb.toLowerCase(); 2987 | if (context) { 2988 | if (context instanceof Element || isType(context, 'String')) { 2989 | return issueAjaxRequest(verb, path, null, null, { 2990 | targetOverride: resolveTarget(context), 2991 | returnPromise: true 2992 | }); 2993 | } else { 2994 | return issueAjaxRequest(verb, path, resolveTarget(context.source), context.event, 2995 | { 2996 | handler : context.handler, 2997 | headers : context.headers, 2998 | values : context.values, 2999 | targetOverride: resolveTarget(context.target), 3000 | swapOverride: context.swap, 3001 | select: context.select, 3002 | returnPromise: true 3003 | }); 3004 | } 3005 | } else { 3006 | return issueAjaxRequest(verb, path, null, null, { 3007 | returnPromise: true 3008 | }); 3009 | } 3010 | } 3011 | 3012 | function hierarchyForElt(elt) { 3013 | var arr = []; 3014 | while (elt) { 3015 | arr.push(elt); 3016 | elt = elt.parentElement; 3017 | } 3018 | return arr; 3019 | } 3020 | 3021 | function verifyPath(elt, path, requestConfig) { 3022 | var sameHost 3023 | var url 3024 | if (typeof URL === "function") { 3025 | url = new URL(path, document.location.href); 3026 | var origin = document.location.origin; 3027 | sameHost = origin === url.origin; 3028 | } else { 3029 | // IE11 doesn't support URL 3030 | url = path 3031 | sameHost = startsWith(path, document.location.origin) 3032 | } 3033 | 3034 | if (htmx.config.selfRequestsOnly) { 3035 | if (!sameHost) { 3036 | return false; 3037 | } 3038 | } 3039 | return triggerEvent(elt, "htmx:validateUrl", mergeObjects({url: url, sameHost: sameHost}, requestConfig)); 3040 | } 3041 | 3042 | function issueAjaxRequest(verb, path, elt, event, etc, confirmed) { 3043 | var resolve = null; 3044 | var reject = null; 3045 | etc = etc != null ? etc : {}; 3046 | if(etc.returnPromise && typeof Promise !== "undefined"){ 3047 | var promise = new Promise(function (_resolve, _reject) { 3048 | resolve = _resolve; 3049 | reject = _reject; 3050 | }); 3051 | } 3052 | if(elt == null) { 3053 | elt = getDocument().body; 3054 | } 3055 | var responseHandler = etc.handler || handleAjaxResponse; 3056 | var select = etc.select || null; 3057 | 3058 | if (!bodyContains(elt)) { 3059 | // do not issue requests for elements removed from the DOM 3060 | maybeCall(resolve); 3061 | return promise; 3062 | } 3063 | var target = etc.targetOverride || getTarget(elt); 3064 | if (target == null || target == DUMMY_ELT) { 3065 | triggerErrorEvent(elt, 'htmx:targetError', {target: getAttributeValue(elt, "hx-target")}); 3066 | maybeCall(reject); 3067 | return promise; 3068 | } 3069 | 3070 | var eltData = getInternalData(elt); 3071 | var submitter = eltData.lastButtonClicked; 3072 | 3073 | if (submitter) { 3074 | var buttonPath = getRawAttribute(submitter, "formaction"); 3075 | if (buttonPath != null) { 3076 | path = buttonPath; 3077 | } 3078 | 3079 | var buttonVerb = getRawAttribute(submitter, "formmethod") 3080 | if (buttonVerb != null) { 3081 | // ignore buttons with formmethod="dialog" 3082 | if (buttonVerb.toLowerCase() !== "dialog") { 3083 | verb = buttonVerb; 3084 | } 3085 | } 3086 | } 3087 | 3088 | var confirmQuestion = getClosestAttributeValue(elt, "hx-confirm"); 3089 | // allow event-based confirmation w/ a callback 3090 | if (confirmed === undefined) { 3091 | var issueRequest = function(skipConfirmation) { 3092 | return issueAjaxRequest(verb, path, elt, event, etc, !!skipConfirmation); 3093 | } 3094 | var confirmDetails = {target: target, elt: elt, path: path, verb: verb, triggeringEvent: event, etc: etc, issueRequest: issueRequest, question: confirmQuestion}; 3095 | if (triggerEvent(elt, 'htmx:confirm', confirmDetails) === false) { 3096 | maybeCall(resolve); 3097 | return promise; 3098 | } 3099 | } 3100 | 3101 | var syncElt = elt; 3102 | var syncStrategy = getClosestAttributeValue(elt, "hx-sync"); 3103 | var queueStrategy = null; 3104 | var abortable = false; 3105 | if (syncStrategy) { 3106 | var syncStrings = syncStrategy.split(":"); 3107 | var selector = syncStrings[0].trim(); 3108 | if (selector === "this") { 3109 | syncElt = findThisElement(elt, 'hx-sync'); 3110 | } else { 3111 | syncElt = querySelectorExt(elt, selector); 3112 | } 3113 | // default to the drop strategy 3114 | syncStrategy = (syncStrings[1] || 'drop').trim(); 3115 | eltData = getInternalData(syncElt); 3116 | if (syncStrategy === "drop" && eltData.xhr && eltData.abortable !== true) { 3117 | maybeCall(resolve); 3118 | return promise; 3119 | } else if (syncStrategy === "abort") { 3120 | if (eltData.xhr) { 3121 | maybeCall(resolve); 3122 | return promise; 3123 | } else { 3124 | abortable = true; 3125 | } 3126 | } else if (syncStrategy === "replace") { 3127 | triggerEvent(syncElt, 'htmx:abort'); // abort the current request and continue 3128 | } else if (syncStrategy.indexOf("queue") === 0) { 3129 | var queueStrArray = syncStrategy.split(" "); 3130 | queueStrategy = (queueStrArray[1] || "last").trim(); 3131 | } 3132 | } 3133 | 3134 | if (eltData.xhr) { 3135 | if (eltData.abortable) { 3136 | triggerEvent(syncElt, 'htmx:abort'); // abort the current request and continue 3137 | } else { 3138 | if(queueStrategy == null){ 3139 | if (event) { 3140 | var eventData = getInternalData(event); 3141 | if (eventData && eventData.triggerSpec && eventData.triggerSpec.queue) { 3142 | queueStrategy = eventData.triggerSpec.queue; 3143 | } 3144 | } 3145 | if (queueStrategy == null) { 3146 | queueStrategy = "last"; 3147 | } 3148 | } 3149 | if (eltData.queuedRequests == null) { 3150 | eltData.queuedRequests = []; 3151 | } 3152 | if (queueStrategy === "first" && eltData.queuedRequests.length === 0) { 3153 | eltData.queuedRequests.push(function () { 3154 | issueAjaxRequest(verb, path, elt, event, etc) 3155 | }); 3156 | } else if (queueStrategy === "all") { 3157 | eltData.queuedRequests.push(function () { 3158 | issueAjaxRequest(verb, path, elt, event, etc) 3159 | }); 3160 | } else if (queueStrategy === "last") { 3161 | eltData.queuedRequests = []; // dump existing queue 3162 | eltData.queuedRequests.push(function () { 3163 | issueAjaxRequest(verb, path, elt, event, etc) 3164 | }); 3165 | } 3166 | maybeCall(resolve); 3167 | return promise; 3168 | } 3169 | } 3170 | 3171 | var xhr = new XMLHttpRequest(); 3172 | eltData.xhr = xhr; 3173 | eltData.abortable = abortable; 3174 | var endRequestLock = function(){ 3175 | eltData.xhr = null; 3176 | eltData.abortable = false; 3177 | if (eltData.queuedRequests != null && 3178 | eltData.queuedRequests.length > 0) { 3179 | var queuedRequest = eltData.queuedRequests.shift(); 3180 | queuedRequest(); 3181 | } 3182 | } 3183 | var promptQuestion = getClosestAttributeValue(elt, "hx-prompt"); 3184 | if (promptQuestion) { 3185 | var promptResponse = prompt(promptQuestion); 3186 | // prompt returns null if cancelled and empty string if accepted with no entry 3187 | if (promptResponse === null || 3188 | !triggerEvent(elt, 'htmx:prompt', {prompt: promptResponse, target:target})) { 3189 | maybeCall(resolve); 3190 | endRequestLock(); 3191 | return promise; 3192 | } 3193 | } 3194 | 3195 | if (confirmQuestion && !confirmed) { 3196 | if(!confirm(confirmQuestion)) { 3197 | maybeCall(resolve); 3198 | endRequestLock() 3199 | return promise; 3200 | } 3201 | } 3202 | 3203 | 3204 | var headers = getHeaders(elt, target, promptResponse); 3205 | 3206 | if (verb !== 'get' && !usesFormData(elt)) { 3207 | headers['Content-Type'] = 'application/x-www-form-urlencoded'; 3208 | } 3209 | 3210 | if (etc.headers) { 3211 | headers = mergeObjects(headers, etc.headers); 3212 | } 3213 | var results = getInputValues(elt, verb); 3214 | var errors = results.errors; 3215 | var rawParameters = results.values; 3216 | if (etc.values) { 3217 | rawParameters = mergeObjects(rawParameters, etc.values); 3218 | } 3219 | var expressionVars = getExpressionVars(elt); 3220 | var allParameters = mergeObjects(rawParameters, expressionVars); 3221 | var filteredParameters = filterValues(allParameters, elt); 3222 | 3223 | if (htmx.config.getCacheBusterParam && verb === 'get') { 3224 | filteredParameters['org.htmx.cache-buster'] = getRawAttribute(target, "id") || "true"; 3225 | } 3226 | 3227 | // behavior of anchors w/ empty href is to use the current URL 3228 | if (path == null || path === "") { 3229 | path = getDocument().location.href; 3230 | } 3231 | 3232 | 3233 | var requestAttrValues = getValuesForElement(elt, 'hx-request'); 3234 | 3235 | var eltIsBoosted = getInternalData(elt).boosted; 3236 | 3237 | var useUrlParams = htmx.config.methodsThatUseUrlParams.indexOf(verb) >= 0 3238 | 3239 | var requestConfig = { 3240 | boosted: eltIsBoosted, 3241 | useUrlParams: useUrlParams, 3242 | parameters: filteredParameters, 3243 | unfilteredParameters: allParameters, 3244 | headers:headers, 3245 | target:target, 3246 | verb:verb, 3247 | errors:errors, 3248 | withCredentials: etc.credentials || requestAttrValues.credentials || htmx.config.withCredentials, 3249 | timeout: etc.timeout || requestAttrValues.timeout || htmx.config.timeout, 3250 | path:path, 3251 | triggeringEvent:event 3252 | }; 3253 | 3254 | if(!triggerEvent(elt, 'htmx:configRequest', requestConfig)){ 3255 | maybeCall(resolve); 3256 | endRequestLock(); 3257 | return promise; 3258 | } 3259 | 3260 | // copy out in case the object was overwritten 3261 | path = requestConfig.path; 3262 | verb = requestConfig.verb; 3263 | headers = requestConfig.headers; 3264 | filteredParameters = requestConfig.parameters; 3265 | errors = requestConfig.errors; 3266 | useUrlParams = requestConfig.useUrlParams; 3267 | 3268 | if(errors && errors.length > 0){ 3269 | triggerEvent(elt, 'htmx:validation:halted', requestConfig) 3270 | maybeCall(resolve); 3271 | endRequestLock(); 3272 | return promise; 3273 | } 3274 | 3275 | var splitPath = path.split("#"); 3276 | var pathNoAnchor = splitPath[0]; 3277 | var anchor = splitPath[1]; 3278 | 3279 | var finalPath = path 3280 | if (useUrlParams) { 3281 | finalPath = pathNoAnchor; 3282 | var values = Object.keys(filteredParameters).length !== 0; 3283 | if (values) { 3284 | if (finalPath.indexOf("?") < 0) { 3285 | finalPath += "?"; 3286 | } else { 3287 | finalPath += "&"; 3288 | } 3289 | finalPath += urlEncode(filteredParameters); 3290 | if (anchor) { 3291 | finalPath += "#" + anchor; 3292 | } 3293 | } 3294 | } 3295 | 3296 | if (!verifyPath(elt, finalPath, requestConfig)) { 3297 | triggerErrorEvent(elt, 'htmx:invalidPath', requestConfig) 3298 | maybeCall(reject); 3299 | return promise; 3300 | }; 3301 | 3302 | xhr.open(verb.toUpperCase(), finalPath, true); 3303 | xhr.overrideMimeType("text/html"); 3304 | xhr.withCredentials = requestConfig.withCredentials; 3305 | xhr.timeout = requestConfig.timeout; 3306 | 3307 | // request headers 3308 | if (requestAttrValues.noHeaders) { 3309 | // ignore all headers 3310 | } else { 3311 | for (var header in headers) { 3312 | if (headers.hasOwnProperty(header)) { 3313 | var headerValue = headers[header]; 3314 | safelySetHeaderValue(xhr, header, headerValue); 3315 | } 3316 | } 3317 | } 3318 | 3319 | var responseInfo = { 3320 | xhr: xhr, target: target, requestConfig: requestConfig, etc: etc, boosted: eltIsBoosted, select: select, 3321 | pathInfo: { 3322 | requestPath: path, 3323 | finalRequestPath: finalPath, 3324 | anchor: anchor 3325 | } 3326 | }; 3327 | 3328 | xhr.onload = function () { 3329 | try { 3330 | var hierarchy = hierarchyForElt(elt); 3331 | responseInfo.pathInfo.responsePath = getPathFromResponse(xhr); 3332 | responseHandler(elt, responseInfo); 3333 | removeRequestIndicators(indicators, disableElts); 3334 | triggerEvent(elt, 'htmx:afterRequest', responseInfo); 3335 | triggerEvent(elt, 'htmx:afterOnLoad', responseInfo); 3336 | // if the body no longer contains the element, trigger the event on the closest parent 3337 | // remaining in the DOM 3338 | if (!bodyContains(elt)) { 3339 | var secondaryTriggerElt = null; 3340 | while (hierarchy.length > 0 && secondaryTriggerElt == null) { 3341 | var parentEltInHierarchy = hierarchy.shift(); 3342 | if (bodyContains(parentEltInHierarchy)) { 3343 | secondaryTriggerElt = parentEltInHierarchy; 3344 | } 3345 | } 3346 | if (secondaryTriggerElt) { 3347 | triggerEvent(secondaryTriggerElt, 'htmx:afterRequest', responseInfo); 3348 | triggerEvent(secondaryTriggerElt, 'htmx:afterOnLoad', responseInfo); 3349 | } 3350 | } 3351 | maybeCall(resolve); 3352 | endRequestLock(); 3353 | } catch (e) { 3354 | triggerErrorEvent(elt, 'htmx:onLoadError', mergeObjects({error:e}, responseInfo)); 3355 | throw e; 3356 | } 3357 | } 3358 | xhr.onerror = function () { 3359 | removeRequestIndicators(indicators, disableElts); 3360 | triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo); 3361 | triggerErrorEvent(elt, 'htmx:sendError', responseInfo); 3362 | maybeCall(reject); 3363 | endRequestLock(); 3364 | } 3365 | xhr.onabort = function() { 3366 | removeRequestIndicators(indicators, disableElts); 3367 | triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo); 3368 | triggerErrorEvent(elt, 'htmx:sendAbort', responseInfo); 3369 | maybeCall(reject); 3370 | endRequestLock(); 3371 | } 3372 | xhr.ontimeout = function() { 3373 | removeRequestIndicators(indicators, disableElts); 3374 | triggerErrorEvent(elt, 'htmx:afterRequest', responseInfo); 3375 | triggerErrorEvent(elt, 'htmx:timeout', responseInfo); 3376 | maybeCall(reject); 3377 | endRequestLock(); 3378 | } 3379 | if(!triggerEvent(elt, 'htmx:beforeRequest', responseInfo)){ 3380 | maybeCall(resolve); 3381 | endRequestLock() 3382 | return promise 3383 | } 3384 | var indicators = addRequestIndicatorClasses(elt); 3385 | var disableElts = disableElements(elt); 3386 | 3387 | forEach(['loadstart', 'loadend', 'progress', 'abort'], function(eventName) { 3388 | forEach([xhr, xhr.upload], function (target) { 3389 | target.addEventListener(eventName, function(event){ 3390 | triggerEvent(elt, "htmx:xhr:" + eventName, { 3391 | lengthComputable:event.lengthComputable, 3392 | loaded:event.loaded, 3393 | total:event.total 3394 | }); 3395 | }) 3396 | }); 3397 | }); 3398 | triggerEvent(elt, 'htmx:beforeSend', responseInfo); 3399 | var params = useUrlParams ? null : encodeParamsForBody(xhr, elt, filteredParameters) 3400 | xhr.send(params); 3401 | return promise; 3402 | } 3403 | 3404 | function determineHistoryUpdates(elt, responseInfo) { 3405 | 3406 | var xhr = responseInfo.xhr; 3407 | 3408 | //=========================================== 3409 | // First consult response headers 3410 | //=========================================== 3411 | var pathFromHeaders = null; 3412 | var typeFromHeaders = null; 3413 | if (hasHeader(xhr,/HX-Push:/i)) { 3414 | pathFromHeaders = xhr.getResponseHeader("HX-Push"); 3415 | typeFromHeaders = "push"; 3416 | } else if (hasHeader(xhr,/HX-Push-Url:/i)) { 3417 | pathFromHeaders = xhr.getResponseHeader("HX-Push-Url"); 3418 | typeFromHeaders = "push"; 3419 | } else if (hasHeader(xhr,/HX-Replace-Url:/i)) { 3420 | pathFromHeaders = xhr.getResponseHeader("HX-Replace-Url"); 3421 | typeFromHeaders = "replace"; 3422 | } 3423 | 3424 | // if there was a response header, that has priority 3425 | if (pathFromHeaders) { 3426 | if (pathFromHeaders === "false") { 3427 | return {} 3428 | } else { 3429 | return { 3430 | type: typeFromHeaders, 3431 | path : pathFromHeaders 3432 | } 3433 | } 3434 | } 3435 | 3436 | //=========================================== 3437 | // Next resolve via DOM values 3438 | //=========================================== 3439 | var requestPath = responseInfo.pathInfo.finalRequestPath; 3440 | var responsePath = responseInfo.pathInfo.responsePath; 3441 | 3442 | var pushUrl = getClosestAttributeValue(elt, "hx-push-url"); 3443 | var replaceUrl = getClosestAttributeValue(elt, "hx-replace-url"); 3444 | var elementIsBoosted = getInternalData(elt).boosted; 3445 | 3446 | var saveType = null; 3447 | var path = null; 3448 | 3449 | if (pushUrl) { 3450 | saveType = "push"; 3451 | path = pushUrl; 3452 | } else if (replaceUrl) { 3453 | saveType = "replace"; 3454 | path = replaceUrl; 3455 | } else if (elementIsBoosted) { 3456 | saveType = "push"; 3457 | path = responsePath || requestPath; // if there is no response path, go with the original request path 3458 | } 3459 | 3460 | if (path) { 3461 | // false indicates no push, return empty object 3462 | if (path === "false") { 3463 | return {}; 3464 | } 3465 | 3466 | // true indicates we want to follow wherever the server ended up sending us 3467 | if (path === "true") { 3468 | path = responsePath || requestPath; // if there is no response path, go with the original request path 3469 | } 3470 | 3471 | // restore any anchor associated with the request 3472 | if (responseInfo.pathInfo.anchor && 3473 | path.indexOf("#") === -1) { 3474 | path = path + "#" + responseInfo.pathInfo.anchor; 3475 | } 3476 | 3477 | return { 3478 | type:saveType, 3479 | path: path 3480 | } 3481 | } else { 3482 | return {}; 3483 | } 3484 | } 3485 | 3486 | function handleAjaxResponse(elt, responseInfo) { 3487 | var xhr = responseInfo.xhr; 3488 | var target = responseInfo.target; 3489 | var etc = responseInfo.etc; 3490 | var requestConfig = responseInfo.requestConfig; 3491 | var select = responseInfo.select; 3492 | 3493 | if (!triggerEvent(elt, 'htmx:beforeOnLoad', responseInfo)) return; 3494 | 3495 | if (hasHeader(xhr, /HX-Trigger:/i)) { 3496 | handleTrigger(xhr, "HX-Trigger", elt); 3497 | } 3498 | 3499 | if (hasHeader(xhr, /HX-Location:/i)) { 3500 | saveCurrentPageToHistory(); 3501 | var redirectPath = xhr.getResponseHeader("HX-Location"); 3502 | var swapSpec; 3503 | if (redirectPath.indexOf("{") === 0) { 3504 | swapSpec = parseJSON(redirectPath); 3505 | // what's the best way to throw an error if the user didn't include this 3506 | redirectPath = swapSpec['path']; 3507 | delete swapSpec['path']; 3508 | } 3509 | ajaxHelper('GET', redirectPath, swapSpec).then(function(){ 3510 | pushUrlIntoHistory(redirectPath); 3511 | }); 3512 | return; 3513 | } 3514 | 3515 | var shouldRefresh = hasHeader(xhr, /HX-Refresh:/i) && "true" === xhr.getResponseHeader("HX-Refresh"); 3516 | 3517 | if (hasHeader(xhr, /HX-Redirect:/i)) { 3518 | location.href = xhr.getResponseHeader("HX-Redirect"); 3519 | shouldRefresh && location.reload(); 3520 | return; 3521 | } 3522 | 3523 | if (shouldRefresh) { 3524 | location.reload(); 3525 | return; 3526 | } 3527 | 3528 | if (hasHeader(xhr,/HX-Retarget:/i)) { 3529 | if (xhr.getResponseHeader("HX-Retarget") === "this") { 3530 | responseInfo.target = elt; 3531 | } else { 3532 | responseInfo.target = querySelectorExt(elt, xhr.getResponseHeader("HX-Retarget")); 3533 | } 3534 | } 3535 | 3536 | var historyUpdate = determineHistoryUpdates(elt, responseInfo); 3537 | 3538 | // by default htmx only swaps on 200 return codes and does not swap 3539 | // on 204 'No Content' 3540 | // this can be ovverriden by responding to the htmx:beforeSwap event and 3541 | // overriding the detail.shouldSwap property 3542 | var shouldSwap = xhr.status >= 200 && xhr.status < 400 && xhr.status !== 204; 3543 | var serverResponse = xhr.response; 3544 | var isError = xhr.status >= 400; 3545 | var ignoreTitle = htmx.config.ignoreTitle 3546 | var beforeSwapDetails = mergeObjects({shouldSwap: shouldSwap, serverResponse:serverResponse, isError:isError, ignoreTitle:ignoreTitle }, responseInfo); 3547 | if (!triggerEvent(target, 'htmx:beforeSwap', beforeSwapDetails)) return; 3548 | 3549 | target = beforeSwapDetails.target; // allow re-targeting 3550 | serverResponse = beforeSwapDetails.serverResponse; // allow updating content 3551 | isError = beforeSwapDetails.isError; // allow updating error 3552 | ignoreTitle = beforeSwapDetails.ignoreTitle; // allow updating ignoring title 3553 | 3554 | responseInfo.target = target; // Make updated target available to response events 3555 | responseInfo.failed = isError; // Make failed property available to response events 3556 | responseInfo.successful = !isError; // Make successful property available to response events 3557 | 3558 | if (beforeSwapDetails.shouldSwap) { 3559 | if (xhr.status === 286) { 3560 | cancelPolling(elt); 3561 | } 3562 | 3563 | withExtensions(elt, function (extension) { 3564 | serverResponse = extension.transformResponse(serverResponse, xhr, elt); 3565 | }); 3566 | 3567 | // Save current page if there will be a history update 3568 | if (historyUpdate.type) { 3569 | saveCurrentPageToHistory(); 3570 | } 3571 | 3572 | var swapOverride = etc.swapOverride; 3573 | if (hasHeader(xhr,/HX-Reswap:/i)) { 3574 | swapOverride = xhr.getResponseHeader("HX-Reswap"); 3575 | } 3576 | var swapSpec = getSwapSpecification(elt, swapOverride); 3577 | 3578 | if (swapSpec.hasOwnProperty('ignoreTitle')) { 3579 | ignoreTitle = swapSpec.ignoreTitle; 3580 | } 3581 | 3582 | target.classList.add(htmx.config.swappingClass); 3583 | 3584 | // optional transition API promise callbacks 3585 | var settleResolve = null; 3586 | var settleReject = null; 3587 | 3588 | var doSwap = function () { 3589 | try { 3590 | var activeElt = document.activeElement; 3591 | var selectionInfo = {}; 3592 | try { 3593 | selectionInfo = { 3594 | elt: activeElt, 3595 | // @ts-ignore 3596 | start: activeElt ? activeElt.selectionStart : null, 3597 | // @ts-ignore 3598 | end: activeElt ? activeElt.selectionEnd : null 3599 | }; 3600 | } catch (e) { 3601 | // safari issue - see https://github.com/microsoft/playwright/issues/5894 3602 | } 3603 | 3604 | var selectOverride; 3605 | if (select) { 3606 | selectOverride = select; 3607 | } 3608 | 3609 | if (hasHeader(xhr, /HX-Reselect:/i)) { 3610 | selectOverride = xhr.getResponseHeader("HX-Reselect"); 3611 | } 3612 | 3613 | // if we need to save history, do so, before swapping so that relative resources have the correct base URL 3614 | if (historyUpdate.type) { 3615 | triggerEvent(getDocument().body, 'htmx:beforeHistoryUpdate', mergeObjects({ history: historyUpdate }, responseInfo)); 3616 | if (historyUpdate.type === "push") { 3617 | pushUrlIntoHistory(historyUpdate.path); 3618 | triggerEvent(getDocument().body, 'htmx:pushedIntoHistory', {path: historyUpdate.path}); 3619 | } else { 3620 | replaceUrlInHistory(historyUpdate.path); 3621 | triggerEvent(getDocument().body, 'htmx:replacedInHistory', {path: historyUpdate.path}); 3622 | } 3623 | } 3624 | 3625 | var settleInfo = makeSettleInfo(target); 3626 | selectAndSwap(swapSpec.swapStyle, target, elt, serverResponse, settleInfo, selectOverride); 3627 | 3628 | if (selectionInfo.elt && 3629 | !bodyContains(selectionInfo.elt) && 3630 | getRawAttribute(selectionInfo.elt, "id")) { 3631 | var newActiveElt = document.getElementById(getRawAttribute(selectionInfo.elt, "id")); 3632 | var focusOptions = { preventScroll: swapSpec.focusScroll !== undefined ? !swapSpec.focusScroll : !htmx.config.defaultFocusScroll }; 3633 | if (newActiveElt) { 3634 | // @ts-ignore 3635 | if (selectionInfo.start && newActiveElt.setSelectionRange) { 3636 | // @ts-ignore 3637 | try { 3638 | newActiveElt.setSelectionRange(selectionInfo.start, selectionInfo.end); 3639 | } catch (e) { 3640 | // the setSelectionRange method is present on fields that don't support it, so just let this fail 3641 | } 3642 | } 3643 | newActiveElt.focus(focusOptions); 3644 | } 3645 | } 3646 | 3647 | target.classList.remove(htmx.config.swappingClass); 3648 | forEach(settleInfo.elts, function (elt) { 3649 | if (elt.classList) { 3650 | elt.classList.add(htmx.config.settlingClass); 3651 | } 3652 | triggerEvent(elt, 'htmx:afterSwap', responseInfo); 3653 | }); 3654 | 3655 | if (hasHeader(xhr, /HX-Trigger-After-Swap:/i)) { 3656 | var finalElt = elt; 3657 | if (!bodyContains(elt)) { 3658 | finalElt = getDocument().body; 3659 | } 3660 | handleTrigger(xhr, "HX-Trigger-After-Swap", finalElt); 3661 | } 3662 | 3663 | var doSettle = function () { 3664 | forEach(settleInfo.tasks, function (task) { 3665 | task.call(); 3666 | }); 3667 | forEach(settleInfo.elts, function (elt) { 3668 | if (elt.classList) { 3669 | elt.classList.remove(htmx.config.settlingClass); 3670 | } 3671 | triggerEvent(elt, 'htmx:afterSettle', responseInfo); 3672 | }); 3673 | 3674 | if (responseInfo.pathInfo.anchor) { 3675 | var anchorTarget = getDocument().getElementById(responseInfo.pathInfo.anchor); 3676 | if(anchorTarget) { 3677 | anchorTarget.scrollIntoView({block:'start', behavior: "auto"}); 3678 | } 3679 | } 3680 | 3681 | if(settleInfo.title && !ignoreTitle) { 3682 | var titleElt = find("title"); 3683 | if(titleElt) { 3684 | titleElt.innerHTML = settleInfo.title; 3685 | } else { 3686 | window.document.title = settleInfo.title; 3687 | } 3688 | } 3689 | 3690 | updateScrollState(settleInfo.elts, swapSpec); 3691 | 3692 | if (hasHeader(xhr, /HX-Trigger-After-Settle:/i)) { 3693 | var finalElt = elt; 3694 | if (!bodyContains(elt)) { 3695 | finalElt = getDocument().body; 3696 | } 3697 | handleTrigger(xhr, "HX-Trigger-After-Settle", finalElt); 3698 | } 3699 | maybeCall(settleResolve); 3700 | } 3701 | 3702 | if (swapSpec.settleDelay > 0) { 3703 | setTimeout(doSettle, swapSpec.settleDelay) 3704 | } else { 3705 | doSettle(); 3706 | } 3707 | } catch (e) { 3708 | triggerErrorEvent(elt, 'htmx:swapError', responseInfo); 3709 | maybeCall(settleReject); 3710 | throw e; 3711 | } 3712 | }; 3713 | 3714 | var shouldTransition = htmx.config.globalViewTransitions 3715 | if(swapSpec.hasOwnProperty('transition')){ 3716 | shouldTransition = swapSpec.transition; 3717 | } 3718 | 3719 | if(shouldTransition && 3720 | triggerEvent(elt, 'htmx:beforeTransition', responseInfo) && 3721 | typeof Promise !== "undefined" && document.startViewTransition){ 3722 | var settlePromise = new Promise(function (_resolve, _reject) { 3723 | settleResolve = _resolve; 3724 | settleReject = _reject; 3725 | }); 3726 | // wrap the original doSwap() in a call to startViewTransition() 3727 | var innerDoSwap = doSwap; 3728 | doSwap = function() { 3729 | document.startViewTransition(function () { 3730 | innerDoSwap(); 3731 | return settlePromise; 3732 | }); 3733 | } 3734 | } 3735 | 3736 | 3737 | if (swapSpec.swapDelay > 0) { 3738 | setTimeout(doSwap, swapSpec.swapDelay) 3739 | } else { 3740 | doSwap(); 3741 | } 3742 | } 3743 | if (isError) { 3744 | triggerErrorEvent(elt, 'htmx:responseError', mergeObjects({error: "Response Status Error Code " + xhr.status + " from " + responseInfo.pathInfo.requestPath}, responseInfo)); 3745 | } 3746 | } 3747 | 3748 | //==================================================================== 3749 | // Extensions API 3750 | //==================================================================== 3751 | 3752 | /** @type {Object} */ 3753 | var extensions = {}; 3754 | 3755 | /** 3756 | * extensionBase defines the default functions for all extensions. 3757 | * @returns {import("./htmx").HtmxExtension} 3758 | */ 3759 | function extensionBase() { 3760 | return { 3761 | init: function(api) {return null;}, 3762 | onEvent : function(name, evt) {return true;}, 3763 | transformResponse : function(text, xhr, elt) {return text;}, 3764 | isInlineSwap : function(swapStyle) {return false;}, 3765 | handleSwap : function(swapStyle, target, fragment, settleInfo) {return false;}, 3766 | encodeParameters : function(xhr, parameters, elt) {return null;} 3767 | } 3768 | } 3769 | 3770 | /** 3771 | * defineExtension initializes the extension and adds it to the htmx registry 3772 | * 3773 | * @param {string} name 3774 | * @param {import("./htmx").HtmxExtension} extension 3775 | */ 3776 | function defineExtension(name, extension) { 3777 | if(extension.init) { 3778 | extension.init(internalAPI) 3779 | } 3780 | extensions[name] = mergeObjects(extensionBase(), extension); 3781 | } 3782 | 3783 | /** 3784 | * removeExtension removes an extension from the htmx registry 3785 | * 3786 | * @param {string} name 3787 | */ 3788 | function removeExtension(name) { 3789 | delete extensions[name]; 3790 | } 3791 | 3792 | /** 3793 | * getExtensions searches up the DOM tree to return all extensions that can be applied to a given element 3794 | * 3795 | * @param {HTMLElement} elt 3796 | * @param {import("./htmx").HtmxExtension[]=} extensionsToReturn 3797 | * @param {import("./htmx").HtmxExtension[]=} extensionsToIgnore 3798 | */ 3799 | function getExtensions(elt, extensionsToReturn, extensionsToIgnore) { 3800 | 3801 | if (elt == undefined) { 3802 | return extensionsToReturn; 3803 | } 3804 | if (extensionsToReturn == undefined) { 3805 | extensionsToReturn = []; 3806 | } 3807 | if (extensionsToIgnore == undefined) { 3808 | extensionsToIgnore = []; 3809 | } 3810 | var extensionsForElement = getAttributeValue(elt, "hx-ext"); 3811 | if (extensionsForElement) { 3812 | forEach(extensionsForElement.split(","), function(extensionName){ 3813 | extensionName = extensionName.replace(/ /g, ''); 3814 | if (extensionName.slice(0, 7) == "ignore:") { 3815 | extensionsToIgnore.push(extensionName.slice(7)); 3816 | return; 3817 | } 3818 | if (extensionsToIgnore.indexOf(extensionName) < 0) { 3819 | var extension = extensions[extensionName]; 3820 | if (extension && extensionsToReturn.indexOf(extension) < 0) { 3821 | extensionsToReturn.push(extension); 3822 | } 3823 | } 3824 | }); 3825 | } 3826 | return getExtensions(parentElt(elt), extensionsToReturn, extensionsToIgnore); 3827 | } 3828 | 3829 | //==================================================================== 3830 | // Initialization 3831 | //==================================================================== 3832 | var isReady = false 3833 | getDocument().addEventListener('DOMContentLoaded', function() { 3834 | isReady = true 3835 | }) 3836 | 3837 | /** 3838 | * Execute a function now if DOMContentLoaded has fired, otherwise listen for it. 3839 | * 3840 | * This function uses isReady because there is no realiable way to ask the browswer whether 3841 | * the DOMContentLoaded event has already been fired; there's a gap between DOMContentLoaded 3842 | * firing and readystate=complete. 3843 | */ 3844 | function ready(fn) { 3845 | // Checking readyState here is a failsafe in case the htmx script tag entered the DOM by 3846 | // some means other than the initial page load. 3847 | if (isReady || getDocument().readyState === 'complete') { 3848 | fn(); 3849 | } else { 3850 | getDocument().addEventListener('DOMContentLoaded', fn); 3851 | } 3852 | } 3853 | 3854 | function insertIndicatorStyles() { 3855 | if (htmx.config.includeIndicatorStyles !== false) { 3856 | getDocument().head.insertAdjacentHTML("beforeend", 3857 | ""); 3862 | } 3863 | } 3864 | 3865 | function getMetaConfig() { 3866 | var element = getDocument().querySelector('meta[name="htmx-config"]'); 3867 | if (element) { 3868 | // @ts-ignore 3869 | return parseJSON(element.content); 3870 | } else { 3871 | return null; 3872 | } 3873 | } 3874 | 3875 | function mergeMetaConfig() { 3876 | var metaConfig = getMetaConfig(); 3877 | if (metaConfig) { 3878 | htmx.config = mergeObjects(htmx.config , metaConfig) 3879 | } 3880 | } 3881 | 3882 | // initialize the document 3883 | ready(function () { 3884 | mergeMetaConfig(); 3885 | insertIndicatorStyles(); 3886 | var body = getDocument().body; 3887 | processNode(body); 3888 | var restoredElts = getDocument().querySelectorAll( 3889 | "[hx-trigger='restored'],[data-hx-trigger='restored']" 3890 | ); 3891 | body.addEventListener("htmx:abort", function (evt) { 3892 | var target = evt.target; 3893 | var internalData = getInternalData(target); 3894 | if (internalData && internalData.xhr) { 3895 | internalData.xhr.abort(); 3896 | } 3897 | }); 3898 | /** @type {(ev: PopStateEvent) => any} */ 3899 | const originalPopstate = window.onpopstate ? window.onpopstate.bind(window) : null; 3900 | /** @type {(ev: PopStateEvent) => any} */ 3901 | window.onpopstate = function (event) { 3902 | if (event.state && event.state.htmx) { 3903 | restoreHistory(); 3904 | forEach(restoredElts, function(elt){ 3905 | triggerEvent(elt, 'htmx:restored', { 3906 | 'document': getDocument(), 3907 | 'triggerEvent': triggerEvent 3908 | }); 3909 | }); 3910 | } else { 3911 | if (originalPopstate) { 3912 | originalPopstate(event); 3913 | } 3914 | } 3915 | }; 3916 | setTimeout(function () { 3917 | triggerEvent(body, 'htmx:load', {}); // give ready handlers a chance to load up before firing this event 3918 | body = null; // kill reference for gc 3919 | }, 0); 3920 | }) 3921 | 3922 | return htmx; 3923 | } 3924 | )() 3925 | })); 3926 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | fn main() { 4 | println!("cargo:rerun-if-changed=templates"); 5 | println!("cargo:rerun-if-changed=assets"); 6 | 7 | std::fs::remove_dir_all("build").unwrap_or_default(); 8 | 9 | Command::new("bun") 10 | .args([ 11 | "run", 12 | "tailwindcss", 13 | "-c", 14 | "tailwind.config.js", 15 | "-i", 16 | "assets/styles/index.css", 17 | "-o", 18 | "build/index.css", 19 | "--minify", 20 | ]) 21 | .status() 22 | .expect("failed to run tailwindcss"); 23 | 24 | Command::new("bun") 25 | .args([ 26 | "build", 27 | "--minify", 28 | "--outdir=build", 29 | "--entry-naming", 30 | "[name].[hash].[ext]", 31 | "--asset-naming", 32 | "[name].[hash].[ext]", 33 | "./assets/scripts/index.ts", 34 | ]) 35 | .status() 36 | .expect("failed to run bun"); 37 | 38 | std::fs::remove_file("build/index.css").unwrap_or_default(); 39 | copy_files("public"); 40 | } 41 | 42 | fn copy_files(dir: &str) { 43 | for entry in std::fs::read_dir(dir).expect("failed to read dir `public`") { 44 | let entry = entry.expect("failed to read entry"); 45 | 46 | if entry.file_type().unwrap().is_dir() { 47 | copy_files(entry.path().to_str().unwrap()); 48 | } else { 49 | let path = entry.path(); 50 | let filename = path.file_name().unwrap().to_str().unwrap(); 51 | let dest = format!("build/{}", filename); 52 | 53 | std::fs::copy(path, dest).expect("failed to copy file"); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Crate" 3 | reorder_imports = true 4 | -------------------------------------------------------------------------------- /src/api_error.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | }; 5 | 6 | #[derive(Debug)] 7 | pub enum ApiError { 8 | TemplateNotFound(String), 9 | TemplateRender(String), 10 | } 11 | 12 | impl IntoResponse for ApiError { 13 | fn into_response(self) -> Response { 14 | let (status_code, message) = match self { 15 | Self::TemplateNotFound(template_name) => ( 16 | StatusCode::NOT_FOUND, 17 | format!("template \"{template_name}\" does not exist"), 18 | ), 19 | Self::TemplateRender(template_name) => ( 20 | StatusCode::INTERNAL_SERVER_ERROR, 21 | format!("failed to render template \"{template_name}\""), 22 | ), 23 | }; 24 | 25 | (status_code, message).into_response() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/asset_cache.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use async_compression::tokio::write::BrotliEncoder; 4 | use axum::extract::Path; 5 | use bytes::Bytes; 6 | use tokio::io::AsyncWriteExt; 7 | 8 | /// A shared reference to the static asset cache. 9 | pub type SharedAssetCache = &'static AssetCache; 10 | 11 | const HASH_SPLIT_CHAR: char = '.'; 12 | 13 | /// Maps static asset filenames to their compressed bytes and content type. This 14 | /// is used to serve static assets from the build directory without reading from 15 | /// disk, as the cache stays in RAM for the life of the server. 16 | /// 17 | /// This type should be accessed via the `cache` property in `AppState`. 18 | pub struct AssetCache(HashMap); 19 | 20 | impl AssetCache { 21 | /// Attempts to return a static asset from the cache from a cache key. If 22 | /// the asset is not found, `None` is returned. 23 | pub fn get(&self, key: &str) -> Option<&StaticAsset> { 24 | self.0.get(key) 25 | } 26 | 27 | /// Helper method to get a static asset from an extracted request path. 28 | pub fn get_from_path(&self, path: &Path) -> Option<&StaticAsset> { 29 | let key = Self::get_cache_key(path); 30 | self.get(&key) 31 | } 32 | 33 | fn get_cache_key(path: &str) -> String { 34 | let mut parts = path.split(|c| c == '.' || c == HASH_SPLIT_CHAR); 35 | 36 | let basename = parts.next().unwrap_or_default(); 37 | let ext = parts.last().unwrap_or_default(); 38 | 39 | format!("{}.{}", basename, ext) 40 | } 41 | 42 | pub async fn load_files() -> Self { 43 | let mut cache = HashMap::default(); 44 | 45 | let assets: Vec<_> = std::fs::read_dir("build") 46 | .unwrap_or_else(|e| panic!("failed to read build directory: {}", e)) 47 | .filter_map(Result::ok) 48 | .filter_map(|file| { 49 | let path = file.path(); 50 | let filename = path.file_name()?.to_str()?; 51 | let ext = path.extension()?.to_str()?; 52 | 53 | let stored_path = path 54 | .clone() 55 | .into_os_string() 56 | .into_string() 57 | .ok()? 58 | .replace("build/", "assets/"); 59 | 60 | std::fs::read(&path) 61 | .ok() 62 | .map(|bytes| (stored_path, bytes, ext.to_string(), filename.to_string())) 63 | }) 64 | .collect(); 65 | 66 | for (stored_path, bytes, ext, filename) in assets { 67 | let contents = match ext.as_str() { 68 | "css" | "js" => compress_data(&bytes).await.unwrap_or_default(), 69 | _ => bytes.into(), 70 | }; 71 | 72 | cache.insert( 73 | Self::get_cache_key(&filename), 74 | StaticAsset { 75 | path: stored_path, 76 | contents, 77 | }, 78 | ); 79 | } 80 | 81 | tracing::debug!("loaded {} assets", cache.len()); 82 | for (key, asset) in &cache { 83 | tracing::debug!("{} -> {}", key, asset.path); 84 | } 85 | 86 | Self(cache) 87 | } 88 | 89 | /// Returns an iterator over the static assets in the cache. 90 | pub fn values(&self) -> impl Iterator { 91 | self.0.values() 92 | } 93 | 94 | /// Returns an iterator over the static asset cache keys. 95 | pub fn keys(&self) -> impl Iterator { 96 | self.0.keys() 97 | } 98 | } 99 | 100 | /// Represents a single static asset from the build directory. Assets are 101 | /// represented as pre-compressed bytes via Brotli and their original content 102 | /// type so the set_content_type middleware service can set the correct 103 | /// Content-Type header. 104 | pub struct StaticAsset { 105 | pub path: String, 106 | pub contents: Bytes, 107 | } 108 | 109 | impl StaticAsset { 110 | /// Returns the content type of the asset based on its file extension. 111 | pub fn ext(&self) -> Option<&str> { 112 | let parts: Vec<&str> = self.path.split('.').collect(); 113 | 114 | parts.last().copied() 115 | } 116 | } 117 | 118 | async fn compress_data(bytes: &[u8]) -> Result { 119 | let mut encoder = 120 | BrotliEncoder::with_quality(Vec::new(), async_compression::Level::Precise(11)); 121 | 122 | encoder.write_all(bytes).await?; 123 | encoder.shutdown().await?; 124 | 125 | Ok(Bytes::from(encoder.into_inner())) 126 | } 127 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{env, error, net::SocketAddr, path::PathBuf}; 2 | 3 | use axum::http::HeaderValue; 4 | 5 | pub struct Config { 6 | pub host: String, 7 | pub port: u16, 8 | pub cors_origin: HeaderValue, 9 | pub postgres_url: String, 10 | pub encryption_key: String, 11 | } 12 | 13 | impl Config { 14 | pub fn new(path: &str) -> Self { 15 | Config::set_vars(path).expect("failed to set env vars"); 16 | 17 | #[rustfmt::skip] 18 | let host = std::env::var("HOST") 19 | .unwrap_or_else(|_| "127.0.0.1".to_string()); 20 | 21 | let port = std::env::var("PORT") 22 | .unwrap_or_else(|_| "3000".to_string()) 23 | .parse() 24 | .expect("PORT must be a number"); 25 | 26 | #[rustfmt::skip] 27 | let cors_origin = std::env::var("CORS_ORIGIN") 28 | .unwrap_or_else(|_| "http://127.0.0.1:8000".to_string()); 29 | 30 | #[rustfmt::skip] 31 | let postgres_url = std::env::var("POSTGRES_URL") 32 | .unwrap_or_else(|_| 33 | "postgres://postgres:postgres@localhost:5432/postgres" 34 | .to_string() 35 | ); 36 | 37 | let set_insecure_encryption_key = || { 38 | tracing::warn!("ENCRYPTION_KEY not set. Reverting to a default key -- this is NOT SAFE FOR PRODUCTION. This must be set to at least 64-bytes generated from a CSPRNG (eg. openssl rand -base64 64)."); 39 | 40 | "dLvewSBvt0VNAJX4p7HLvBAfIeltnMCeOBHgzh7FBrDeysTm4FTkAVvEH4ydFdNezrGY65dy99lWSCTrb27IIA==".to_string() 41 | }; 42 | 43 | #[rustfmt::skip] 44 | let encryption_key = match std::env::var("ENCRYPTION_KEY") { 45 | Ok(key) => { 46 | if key.len() < 64 { 47 | set_insecure_encryption_key() 48 | } else { 49 | key 50 | } 51 | } 52 | Err(_) => set_insecure_encryption_key(), 53 | }; 54 | 55 | Config::unset_vars(); 56 | 57 | Config { 58 | host, 59 | port, 60 | cors_origin: cors_origin.parse().expect("failed to parse CORS origin"), 61 | postgres_url, 62 | encryption_key, 63 | } 64 | } 65 | 66 | pub fn addr(&self) -> SocketAddr { 67 | let ip = self.host.parse().expect("failed to parse IP address"); 68 | 69 | SocketAddr::new(ip, self.port) 70 | } 71 | 72 | /// Set environment variables from a given file. 73 | fn set_vars(path: impl Into) -> Result<(), Box> { 74 | let file = std::fs::read_to_string(path.into())?; 75 | 76 | for line in file.lines() { 77 | let line = line.trim(); 78 | 79 | if line.is_empty() || line.starts_with('#') || line.contains('\0') { 80 | continue; 81 | } 82 | 83 | let parts = line.splitn(2, '=').collect::>(); 84 | 85 | let key = parts[0].trim().to_string(); 86 | let value = parts[1].trim().to_string(); 87 | 88 | if key.is_empty() || key.contains(['=']) || value.is_empty() { 89 | continue; 90 | } 91 | 92 | env::set_var(key, value) 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | /// Unset the environment variables set by `Config::set_vars`. 99 | fn unset_vars() { 100 | env::remove_var("HOST"); 101 | env::remove_var("PORT"); 102 | env::remove_var("CORS_ORIGIN"); 103 | env::remove_var("POSTGRES_URL"); 104 | env::remove_var("ENCRYPTION_KEY"); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | mod api_error; 3 | mod asset_cache; 4 | mod config; 5 | mod routes; 6 | mod state; 7 | 8 | use std::{error, ffi::OsStr, time::Duration}; 9 | 10 | use axum::{ 11 | extract::{Path, Request, State}, 12 | http::{ 13 | header::{ACCEPT, CONTENT_ENCODING, CONTENT_TYPE}, 14 | HeaderMap, HeaderName, HeaderValue, Method, StatusCode, 15 | }, 16 | middleware::{self, Next}, 17 | response::{Html, IntoResponse, Response}, 18 | routing::get, 19 | Router, 20 | }; 21 | use axum_extra::extract::cookie::Key; 22 | use config::Config; 23 | use deadpool::Runtime; 24 | use deadpool_postgres::Config as PgConfig; 25 | use minijinja::Environment; 26 | use routes::{ 27 | index::{about, index}, 28 | not_found::not_found, 29 | robots::robots, 30 | }; 31 | use state::SharedState; 32 | use tokio_postgres::NoTls; 33 | use tower_http::{ 34 | compression::{predicate::SizeAbove, CompressionLayer}, 35 | cors::CorsLayer, 36 | CompressionLevel, 37 | }; 38 | use tracing_subscriber::{prelude::*, EnvFilter}; 39 | 40 | use crate::{asset_cache::AssetCache, routes::BaseTemplateData, state::AppState}; 41 | 42 | pub type BoxedError = Box; 43 | 44 | /// Leak a value as a static reference. 45 | pub fn leak_alloc(value: T) -> &'static T { 46 | Box::leak(Box::new(value)) 47 | } 48 | 49 | #[tokio::main] 50 | async fn main() -> Result<(), BoxedError> { 51 | tracing_subscriber::registry() 52 | .with(tracing_subscriber::fmt::layer()) 53 | .with(EnvFilter::from_default_env()) 54 | .init(); 55 | 56 | let config = Config::new(".env"); 57 | 58 | let pg = { 59 | let mut pg_config = PgConfig::new(); 60 | pg_config.url = Some(config.postgres_url.clone()); 61 | 62 | pg_config.create_pool(Some(Runtime::Tokio1), NoTls)? 63 | }; 64 | 65 | let assets = leak_alloc(AssetCache::load_files().await); 66 | let base_template_data = leak_alloc(BaseTemplateData::new(assets)); 67 | let env = import_templates()?; 68 | 69 | let app_state = leak_alloc(AppState { 70 | pg, 71 | assets, 72 | base_template_data, 73 | env, 74 | encryption_key: Key::from(config.encryption_key.as_bytes()), 75 | }); 76 | 77 | let router = Router::new() 78 | .merge(route_handler(app_state)) 79 | .nest("/api", api_handler(app_state)) 80 | .nest("/assets", static_file_handler(app_state)); 81 | 82 | tracing::info!("Listening on {}", config.addr()); 83 | 84 | let listener = tokio::net::TcpListener::bind(&config.addr()).await?; 85 | 86 | axum::serve( 87 | listener, 88 | router 89 | .layer( 90 | CorsLayer::new() 91 | .allow_credentials(true) 92 | .allow_headers([ACCEPT, CONTENT_TYPE, HeaderName::from_static("csrf-token")]) 93 | .max_age(Duration::from_secs(86400)) 94 | .allow_origin(config.cors_origin) 95 | .allow_methods([ 96 | Method::GET, 97 | Method::POST, 98 | Method::PUT, 99 | Method::DELETE, 100 | Method::OPTIONS, 101 | Method::HEAD, 102 | Method::PATCH, 103 | ]), 104 | ) 105 | .layer( 106 | CompressionLayer::new() 107 | .quality(CompressionLevel::Precise(4)) 108 | .compress_when(SizeAbove::new(512)), 109 | ) 110 | .into_make_service(), 111 | ) 112 | .await?; 113 | 114 | Ok(()) 115 | } 116 | 117 | fn static_file_handler(state: SharedState) -> Router { 118 | Router::new() 119 | .route( 120 | "/:file", 121 | get(|state: State, path: Path| async move { 122 | let Some(asset) = state.assets.get_from_path(&path) else { 123 | return StatusCode::NOT_FOUND.into_response(); 124 | }; 125 | 126 | let mut headers = HeaderMap::new(); 127 | 128 | // We set the content type explicitly here as it will otherwise 129 | // be inferred as an `octet-stream` 130 | headers.insert( 131 | CONTENT_TYPE, 132 | HeaderValue::from_static(asset.ext().unwrap_or("")), 133 | ); 134 | 135 | if [Some("css"), Some("js")].contains(&asset.ext()) { 136 | headers.insert(CONTENT_ENCODING, HeaderValue::from_static("br")); 137 | } 138 | 139 | // `bytes::Bytes` clones are cheap 140 | (headers, asset.contents.clone()).into_response() 141 | }), 142 | ) 143 | .layer(middleware::from_fn(cache_control)) 144 | .with_state(state) 145 | } 146 | 147 | fn route_handler(state: SharedState) -> Router { 148 | Router::new() 149 | .route("/", get(index)) 150 | .route("/about", get(about)) 151 | .route("/robots.txt", get(robots)) 152 | .fallback(not_found) 153 | .with_state(state) 154 | .layer(middleware::from_fn(cache_control)) 155 | } 156 | 157 | fn api_handler(state: SharedState) -> Router { 158 | Router::new() 159 | .route("/health", get(|| async { Html("OK") })) 160 | .fallback(not_found) 161 | .with_state(state) 162 | } 163 | 164 | fn import_templates() -> Result, BoxedError> { 165 | let mut env = Environment::new(); 166 | 167 | for entry in std::fs::read_dir("templates")?.filter_map(Result::ok) { 168 | let path = entry.path(); 169 | 170 | if path.is_file() && path.extension() == Some(OsStr::new("html")) { 171 | let name = path 172 | .file_name() 173 | .and_then(OsStr::to_str) 174 | .ok_or("failed to convert path to string")? 175 | .to_owned(); 176 | 177 | let data = std::fs::read_to_string(&path)?; 178 | 179 | env.add_template_owned(name, data)?; 180 | } 181 | } 182 | 183 | Ok(env) 184 | } 185 | 186 | async fn cache_control(request: Request, next: Next) -> Response { 187 | let mut response = next.run(request).await; 188 | 189 | if let Some(content_type) = response.headers().get(CONTENT_TYPE) { 190 | const CACHEABLE_CONTENT_TYPES: [&str; 6] = [ 191 | "text/css", 192 | "application/javascript", 193 | "image/svg+xml", 194 | "image/webp", 195 | "font/woff2", 196 | "image/png", 197 | ]; 198 | 199 | if CACHEABLE_CONTENT_TYPES.iter().any(|&ct| content_type == ct) { 200 | let value = format!("public, max-age={}", 60 * 60 * 24); 201 | 202 | if let Ok(value) = HeaderValue::from_str(&value) { 203 | response.headers_mut().insert("cache-control", value); 204 | } 205 | } 206 | } 207 | 208 | response 209 | } 210 | -------------------------------------------------------------------------------- /src/routes/index.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, response::IntoResponse}; 2 | use axum_htmx::HxBoosted; 3 | use serde::Serialize; 4 | 5 | use super::BaseTemplateData; 6 | use crate::state::SharedState; 7 | 8 | #[derive(Serialize)] 9 | struct IndexTemplate { 10 | base: Option, 11 | } 12 | 13 | pub async fn index(boosted: HxBoosted, state: State) -> impl IntoResponse { 14 | state.render(boosted, "index.html") 15 | } 16 | 17 | pub async fn about(boosted: HxBoosted, state: State) -> impl IntoResponse { 18 | state.render(boosted, "about.html") 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | 3 | use crate::asset_cache::SharedAssetCache; 4 | 5 | pub mod index; 6 | pub mod not_found; 7 | pub mod robots; 8 | 9 | pub type SharedBaseTemplateData = &'static BaseTemplateData; 10 | 11 | #[derive(Clone, Serialize)] 12 | pub struct BaseTemplateData { 13 | styles: String, 14 | scripts: String, 15 | } 16 | 17 | impl BaseTemplateData { 18 | pub fn new(assets: SharedAssetCache) -> Self { 19 | let styles = assets 20 | .get("index.css") 21 | .expect("failed to build base template data: index.css") 22 | .path 23 | .clone(); 24 | 25 | let scripts = assets 26 | .get("index.js") 27 | .expect("failed to build base template data: index.js") 28 | .path 29 | .clone(); 30 | 31 | Self { styles, scripts } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/routes/not_found.rs: -------------------------------------------------------------------------------- 1 | use axum::{body::Body, extract::State, http::Request, response::IntoResponse}; 2 | use axum_htmx::HxBoosted; 3 | use minijinja::context; 4 | use serde::Serialize; 5 | 6 | use crate::state::SharedState; 7 | 8 | #[derive(Serialize)] 9 | struct NotFoundTemplate { 10 | pub message: String, 11 | } 12 | 13 | pub async fn not_found( 14 | boosted: HxBoosted, 15 | state: State, 16 | req: Request, 17 | ) -> impl IntoResponse { 18 | let message = format!("{:?} not found", req.uri().path()); 19 | 20 | state.render_with_context( 21 | boosted, 22 | "not_found.html", 23 | context! { 24 | message 25 | }, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/routes/robots.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::State, http::StatusCode, response::IntoResponse}; 2 | 3 | use crate::state::SharedState; 4 | 5 | pub async fn robots(state: State) -> impl IntoResponse { 6 | let Some(asset) = state.assets.get("robots.txt") else { 7 | return StatusCode::NOT_FOUND.into_response(); 8 | }; 9 | 10 | if let Ok(string) = String::from_utf8(asset.contents.clone().to_vec()) { 11 | return string.into_response(); 12 | } 13 | 14 | StatusCode::INTERNAL_SERVER_ERROR.into_response() 15 | } 16 | -------------------------------------------------------------------------------- /src/state.rs: -------------------------------------------------------------------------------- 1 | use axum::{extract::FromRef, response::Html}; 2 | use axum_extra::extract::cookie::Key; 3 | use axum_htmx::HxBoosted; 4 | use deadpool_postgres::Pool as PgPool; 5 | use minijinja::{context, value::Value}; 6 | 7 | use crate::{api_error::ApiError, asset_cache::SharedAssetCache, routes::SharedBaseTemplateData}; 8 | 9 | pub type SharedState = &'static AppState; 10 | 11 | #[derive(Clone)] 12 | pub struct AppState { 13 | pub pg: PgPool, 14 | pub assets: SharedAssetCache, 15 | pub base_template_data: SharedBaseTemplateData, 16 | pub env: minijinja::Environment<'static>, 17 | pub encryption_key: Key, 18 | } 19 | 20 | impl FromRef<&'static AppState> for Key { 21 | fn from_ref(state: &&'static AppState) -> Self { 22 | state.encryption_key.clone() 23 | } 24 | } 25 | 26 | impl AppState { 27 | pub fn render( 28 | &self, 29 | HxBoosted(boosted): HxBoosted, 30 | template: &str, 31 | ) -> Result, ApiError> { 32 | let template = self 33 | .env 34 | .get_template(template) 35 | .map_err(|_| ApiError::TemplateNotFound(template.into()))?; 36 | 37 | if boosted { 38 | match template.render(context! {}) { 39 | Ok(rendered) => return Ok(Html(rendered)), 40 | Err(_) => return Err(ApiError::TemplateRender(template.name().into())), 41 | } 42 | } 43 | 44 | match template.render(context! { 45 | base => Some(self.base_template_data ) 46 | }) { 47 | Ok(rendered) => Ok(Html(rendered)), 48 | Err(_) => Err(ApiError::TemplateRender(template.name().into())), 49 | } 50 | } 51 | 52 | pub fn render_with_context( 53 | &self, 54 | HxBoosted(boosted): HxBoosted, 55 | template: &str, 56 | ctx: Value, 57 | ) -> Result, ApiError> { 58 | let template = self 59 | .env 60 | .get_template(template) 61 | .map_err(|_| ApiError::TemplateNotFound(template.into()))?; 62 | 63 | if boosted { 64 | let rendered = template 65 | .render(ctx) 66 | .map_err(|_| ApiError::TemplateRender(template.name().into()))?; 67 | 68 | return Ok(Html(rendered)); 69 | } 70 | 71 | match template.render(context! { 72 | base => Some(self.base_template_data), ..ctx 73 | }) { 74 | Ok(rendered) => Ok(Html(rendered)), 75 | Err(_) => Err(ApiError::TemplateRender(template.name().into())), 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | export const content = ["./templates/**/*.html"] 2 | export const theme = { 3 | extend: { 4 | colors: { 5 | light: { 6 | primary: "#f8f9fa", 7 | secondary: "#272727", 8 | highlight: "#3f5ab1", 9 | }, 10 | dark: { 11 | primary: "#222831", 12 | secondary: "#dddbd8", 13 | highlight: "#ffc857", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /templates/_base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Template: axum + htmx + tailwind 21 | 22 | 23 | 25 | {% include "navbar.html" %} 26 | 27 |
28 | {% block main %} {% endblock %} 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /templates/_partial.html: -------------------------------------------------------------------------------- 1 |
2 | {% block main %} {% endblock %} 3 |
-------------------------------------------------------------------------------- /templates/about.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" if base is defined else "_partial.html" %} 2 | 3 | {% block main %} 4 | 5 |

About

6 | 7 |

8 | This is the about page! 9 |

10 | 11 | {% endblock %} -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" if base is defined else "_partial.html" %} 2 | 3 | {% block main %} 4 | 5 |

Home

6 | 7 |

8 | This is the home page! 9 |

10 | 11 | {% endblock %} -------------------------------------------------------------------------------- /templates/navbar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/not_found.html: -------------------------------------------------------------------------------- 1 | {% extends "_base.html" if base is defined else "_partial.html" %} 2 | 3 | {% block main %} 4 | 5 |

404 Not Found

6 |

{{ message }}

7 | 8 | Back Home 9 | 10 | {% endblock %} --------------------------------------------------------------------------------