diff --git a/www/static/src/ext/path-params.js b/www/static/src/ext/path-params.js new file mode 100644 index 00000000..e251b892 --- /dev/null +++ b/www/static/src/ext/path-params.js @@ -0,0 +1,11 @@ +htmx.defineExtension('path-params', { + onEvent: function(name, evt) { + if (name === "htmx:configRequest") { + evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function (_, param) { + var val = evt.detail.parameters[param]; + delete evt.detail.parameters[param]; + return val === undefined ? "{" + param + "}" : encodeURIComponent(val); + }) + } + } +}); \ No newline at end of file diff --git a/www/static/src/ext/sse.js b/www/static/src/ext/sse.js index 75c875a1..943d80ac 100644 --- a/www/static/src/ext/sse.js +++ b/www/static/src/ext/sse.js @@ -5,7 +5,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions */ -(function(){ +(function() { /** @type {import("../htmx").HtmxInternalApi} */ var api; @@ -39,17 +39,19 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions switch (name) { - // Try to remove remove an EventSource when elements are removed - case "htmx:beforeCleanupElement": - var internalData = api.getInternalData(evt.target) - if (internalData.sseEventSource) { - internalData.sseEventSource.close(); - } - return; + case "htmx:beforeCleanupElement": + var internalData = api.getInternalData(evt.target) + // Try to remove remove an EventSource when elements are removed + if (internalData.sseEventSource) { + internalData.sseEventSource.close(); + } - // Try to create EventSources when elements are processed - case "htmx:afterProcessNode": - createEventSourceOnElement(evt.target); + return; + + // Try to create EventSources when elements are processed + case "htmx:afterProcessNode": + ensureEventSourceOnElement(evt.target); + registerSSE(evt.target); } } }); @@ -66,8 +68,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions * @param {string} url * @returns EventSource */ - function createEventSource(url) { - return new EventSource(url, {withCredentials:true}); + function createEventSource(url) { + return new EventSource(url, { withCredentials: true }); } function splitOnWhitespace(trigger) { @@ -90,7 +92,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions function getLegacySSESwaps(elt) { var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); var returnArr = []; - if (legacySSEValue) { + if (legacySSEValue != null) { var values = splitOnWhitespace(legacySSEValue); for (var i = 0; i < values.length; i++) { var value = values[i].split(/:(.+)/); @@ -103,63 +105,24 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions } /** - * createEventSourceOnElement creates a new EventSource connection on the provided element. - * If a usable EventSource already exists, then it is returned. If not, then a new EventSource - * is created and stored in the element's internalData. + * registerSSE looks for attributes that can contain sse events, right + * now hx-trigger and sse-swap and adds listeners based on these attributes too + * the closest event source + * * @param {HTMLElement} elt - * @param {number} retryCount - * @returns {EventSource | null} */ - function createEventSourceOnElement(elt, retryCount) { - - if (elt == null) { - return null; + function registerSSE(elt) { + // Find closest existing event source + var sourceElement = api.getClosestMatch(elt, hasEventSource); + if (sourceElement == null) { + // api.triggerErrorEvent(elt, "htmx:noSSESourceError") + return null; // no eventsource in parentage, orphaned element } - var internalData = api.getInternalData(elt); + // Set internalData and source + var internalData = api.getInternalData(sourceElement); + var source = internalData.sseEventSource; - // get URL from element's attribute - var sseURL = api.getAttributeValue(elt, "sse-connect"); - - - if (sseURL == undefined) { - var legacyURL = getLegacySSEURL(elt) - if (legacyURL) { - sseURL = legacyURL; - } else { - return null; - } - } - - // Connect to the EventSource - var source = htmx.createEventSource(sseURL); - internalData.sseEventSource = source; - - // Create event handlers - source.onerror = function (err) { - - // Log an error event - api.triggerErrorEvent(elt, "htmx:sseError", {error:err, source:source}); - - // If parent no longer exists in the document, then clean up this EventSource - if (maybeCloseSSESource(elt)) { - return; - } - - // Otherwise, try to reconnect the EventSource - if (source.readyState === EventSource.CLOSED) { - retryCount = retryCount || 0; - var timeout = Math.random() * (2 ^ retryCount) * 500; - window.setTimeout(function() { - createEventSourceOnElement(elt, Math.min(7, retryCount+1)); - }, timeout); - } - }; - - source.onopen = function (evt) { - api.triggerEvent(elt, "htmx:sseOpen", {source: source}); - } - // Add message handlers for every `sse-swap` attribute queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) { @@ -170,23 +133,27 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions var sseEventNames = getLegacySSESwaps(child); } - for (var i = 0 ; i < sseEventNames.length ; i++) { + for (var i = 0; i < sseEventNames.length; i++) { var sseEventName = sseEventNames[i].trim(); var listener = function(event) { - // If the parent is missing then close SSE and remove listener - if (maybeCloseSSESource(elt)) { - source.removeEventListener(sseEventName, listener); + // If the source is missing then close SSE + if (maybeCloseSSESource(sourceElement)) { return; } + // If the body no longer contains the element, remove the listener + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + // swap the response into the DOM and trigger a notification swap(child, event.data); api.triggerEvent(elt, "htmx:sseMessage", event); }; // Register the new listener - api.getInternalData(elt).sseEventListener = listener; + api.getInternalData(child).sseEventListener = listener; source.addEventListener(sseEventName, listener); } }); @@ -203,24 +170,86 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions if (sseEventName.slice(0, 4) != "sse:") { return; } + + // remove the sse: prefix from here on out + sseEventName = sseEventName.substr(4); - var listener = function(event) { - - // If parent is missing, then close SSE and remove listener - if (maybeCloseSSESource(elt)) { - source.removeEventListener(sseEventName, listener); - return; + var listener = function() { + if (maybeCloseSSESource(sourceElement)) { + return } - // Trigger events to be handled by the rest of htmx - htmx.trigger(child, sseEventName, event); - htmx.trigger(child, "htmx:sseMessage", event); + if (!api.bodyContains(child)) { + source.removeEventListener(sseEventName, listener); + } + } + }); + } + + /** + * ensureEventSourceOnElement creates a new EventSource connection on the provided element. + * If a usable EventSource already exists, then it is returned. If not, then a new EventSource + * is created and stored in the element's internalData. + * @param {HTMLElement} elt + * @param {number} retryCount + * @returns {EventSource | null} + */ + function ensureEventSourceOnElement(elt, retryCount) { + + if (elt == null) { + return null; + } + + // handle extension source creation attribute + queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { + var sseURL = api.getAttributeValue(child, "sse-connect"); + if (sseURL == null) { + return; } - // Register the new listener - api.getInternalData(elt).sseEventListener = listener; - source.addEventListener(sseEventName.slice(4), listener); + ensureEventSource(child, sseURL, retryCount); }); + + // handle legacy sse, remove for HTMX2 + queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { + var sseURL = getLegacySSEURL(child); + if (sseURL == null) { + return; + } + + ensureEventSource(child, sseURL, retryCount); + }); + + } + + function ensureEventSource(elt, url, retryCount) { + var source = htmx.createEventSource(url); + + source.onerror = function(err) { + + // Log an error event + api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); + + // If parent no longer exists in the document, then clean up this EventSource + if (maybeCloseSSESource(elt)) { + return; + } + + // Otherwise, try to reconnect the EventSource + if (source.readyState === EventSource.CLOSED) { + retryCount = retryCount || 0; + var timeout = Math.random() * (2 ^ retryCount) * 500; + window.setTimeout(function() { + ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); + }, timeout); + } + }; + + source.onopen = function(evt) { + api.triggerEvent(elt, "htmx:sseOpen", { source: source }); + } + + api.getInternalData(elt).sseEventSource = source; } /** @@ -253,12 +282,12 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions var result = []; // If the parent element also contains the requested attribute, then add it to the results too. - if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-sse")) { + if (api.hasAttribute(elt, attributeName)) { result.push(elt); } // Search all child nodes that match the requested attribute - elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [hx-sse], [data-hx-sse]").forEach(function(node) { + elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { result.push(node); }); @@ -281,7 +310,7 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); - settleInfo.elts.forEach(function (elt) { + settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.add(htmx.config.settlingClass); } @@ -306,11 +335,11 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions function doSettle(settleInfo) { return function() { - settleInfo.tasks.forEach(function (task) { + settleInfo.tasks.forEach(function(task) { task.call(); }); - settleInfo.elts.forEach(function (elt) { + settleInfo.elts.forEach(function(elt) { if (elt.classList) { elt.classList.remove(htmx.config.settlingClass); } @@ -319,4 +348,8 @@ This extension adds support for Server Sent Events to htmx. See /www/extensions } } -})(); \ No newline at end of file + function hasEventSource(node) { + return api.getInternalData(node).sseEventSource != null; + } + +})(); diff --git a/www/static/src/htmx.d.ts b/www/static/src/htmx.d.ts index 6200cd3f..197ac527 100644 --- a/www/static/src/htmx.d.ts +++ b/www/static/src/htmx.d.ts @@ -400,6 +400,42 @@ export interface HtmxConfig { * @default true */ scrollIntoViewOnBoost?: boolean; + /** + * If set, the nonce will be added to inline scripts. + * @default '' + */ + inlineScriptNonce?: string; + /** + * The type of binary data being received over the WebSocket connection + * @default 'blob' + */ + wsBinaryType?: 'blob' | 'arraybuffer'; + /** + * If set to true htmx will include a cache-busting parameter in GET requests to avoid caching partial responses by the browser + * @default false + */ + getCacheBusterParam?: boolean; + /** + * If set to true, htmx will use the View Transition API when swapping in new content. + * @default false + */ + globalViewTransitions?: boolean; + /** + * htmx will format requests with these methods by encoding their parameters in the URL, not the request body + * @default ["get"] + */ + methodsThatUseUrlParams?: ('get' | 'head' | 'post' | 'put' | 'delete' | 'connect' | 'options' | 'trace' | 'patch' )[]; + /** + * If set to true htmx will not update the title of the document when a title tag is found in new content + * @default false + */ + ignoreTitle:? boolean; + /** + * The cache to store evaluated trigger specifications into. + * You may define a simple object to use a never-clearing cache, or implement your own system using a [proxy object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Proxy) + * @default null + */ + triggerSpecsCache?: {[trigger: string]: HtmxTriggerSpecification[]}; } /** diff --git a/www/static/src/htmx.js b/www/static/src/htmx.js index aa86010f..acd70dc0 100644 --- a/www/static/src/htmx.js +++ b/www/static/src/htmx.js @@ -76,7 +76,8 @@ return (function () { methodsThatUseUrlParams: ["get"], selfRequestsOnly: false, ignoreTitle: false, - scrollIntoViewOnBoost: true + scrollIntoViewOnBoost: true, + triggerSpecsCache: null, }, parseInterval:parseInterval, _:internalEval, @@ -127,24 +128,40 @@ return (function () { return "[hx-" + verb + "], [data-hx-" + verb + "]" }).join(", "); + var HEAD_TAG_REGEX = makeTagRegEx('head'), + TITLE_TAG_REGEX = makeTagRegEx('title'), + SVG_TAGS_REGEX = makeTagRegEx('svg', true); + //==================================================================== // Utilities //==================================================================== + /** + * @param {string} tag + * @param {boolean} global + * @returns {RegExp} + */ + function makeTagRegEx(tag, global = false) { + return new RegExp(`<${tag}(\\s[^>]*>|>)([\\s\\S]*?)<\\/${tag}>`, + global ? 'gim' : 'im'); + } + function parseInterval(str) { if (str == undefined) { - return undefined + return undefined; } + + let interval = NaN; if (str.slice(-2) == "ms") { - return parseFloat(str.slice(0,-2)) || undefined + interval = parseFloat(str.slice(0, -2)); + } else if (str.slice(-1) == "s") { + interval = parseFloat(str.slice(0, -1)) * 1000; + } else if (str.slice(-1) == "m") { + interval = parseFloat(str.slice(0, -1)) * 1000 * 60; + } else { + interval = parseFloat(str); } - if (str.slice(-1) == "s") { - return (parseFloat(str.slice(0,-1)) * 1000) || undefined - } - if (str.slice(-1) == "m") { - return (parseFloat(str.slice(0,-1)) * 1000 * 60) || undefined - } - return parseFloat(str) || undefined + return isNaN(interval) ? undefined : interval; } /** @@ -276,43 +293,46 @@ return (function () { } function aFullPageResponse(resp) { - return resp.match(/
" + resp + "", 0); + var documentFragment = parseHTML("" + content + "", 0); // @ts-ignore type mismatch between DocumentFragment and Element. // TODO: Are these close enough for htmx to use interchangeably? return documentFragment.querySelector('template').content; - } else { - var startTag = getStartTag(resp); - switch (startTag) { - case "thead": - case "tbody": - case "tfoot": - case "colgroup": - case "caption": - return parseHTML("