From 58df7cc1cbf121d15d3e11922639068a94ec3902 Mon Sep 17 00:00:00 2001 From: Simon Martens Date: Sat, 28 Dec 2024 08:15:55 +0100 Subject: [PATCH] XSLT examlpe --- functions/string.go | 9 + templating/engine.go | 1 + views/assets/js/class-tools.js | 97 ++++ views/assets/js/client-side-templates.js | 96 ++++ views/assets/js/head-support.js | 144 ++++++ views/assets/js/include-vals.js | 23 + views/assets/js/loading-states.js | 184 +++++++ views/assets/js/multi-swap.js | 44 ++ views/assets/js/path-params.js | 11 + views/assets/js/preload.js | 388 +++++++++++++++ views/assets/js/ws.js | 471 ++++++++++++++++++ views/assets/scripts.js | 53 +- views/layouts/default/root.gohtml | 1 + views/public/js/class-tools.js | 97 ++++ views/public/js/client-side-templates.js | 96 ++++ views/public/js/head-support.js | 144 ++++++ views/public/js/include-vals.js | 23 + views/public/js/loading-states.js | 184 +++++++ views/public/js/multi-swap.js | 44 ++ views/public/js/path-params.js | 11 + views/public/js/preload.js | 388 +++++++++++++++ views/public/js/ws.js | 471 ++++++++++++++++++ views/routes/akteure/body.gohtml | 10 +- views/routes/components/_xslt_citation.gohtml | 15 + views/transform/main.js | 92 ++-- 25 files changed, 3049 insertions(+), 48 deletions(-) create mode 100644 views/assets/js/class-tools.js create mode 100644 views/assets/js/client-side-templates.js create mode 100644 views/assets/js/head-support.js create mode 100644 views/assets/js/include-vals.js create mode 100644 views/assets/js/loading-states.js create mode 100644 views/assets/js/multi-swap.js create mode 100644 views/assets/js/path-params.js create mode 100644 views/assets/js/preload.js create mode 100644 views/assets/js/ws.js create mode 100644 views/public/js/class-tools.js create mode 100644 views/public/js/client-side-templates.js create mode 100644 views/public/js/head-support.js create mode 100644 views/public/js/include-vals.js create mode 100644 views/public/js/loading-states.js create mode 100644 views/public/js/multi-swap.js create mode 100644 views/public/js/path-params.js create mode 100644 views/public/js/preload.js create mode 100644 views/public/js/ws.js create mode 100644 views/routes/components/_xslt_citation.gohtml diff --git a/functions/string.go b/functions/string.go index 1603b02..4f68ac9 100644 --- a/functions/string.go +++ b/functions/string.go @@ -1,8 +1,17 @@ package functions +import "html/template" + func FirstLetter(s string) string { if len(s) == 0 { return "" } return string(s[:1]) } + +func Safe(s string) template.HTML { + if len(s) == 0 { + return "" + } + return template.HTML(s) +} diff --git a/templating/engine.go b/templating/engine.go index d20fe6d..cd9f5d2 100644 --- a/templating/engine.go +++ b/templating/engine.go @@ -48,6 +48,7 @@ func (e *Engine) Funcs(app *app.KGPZ) error { e.AddFunc("FirstLetter", functions.FirstLetter) e.AddFunc("Upper", strings.ToUpper) e.AddFunc("Lower", strings.ToLower) + e.AddFunc("Safe", functions.Safe) // App specific e.AddFunc("GetAgent", app.Library.Agents.Item) diff --git a/views/assets/js/class-tools.js b/views/assets/js/class-tools.js new file mode 100644 index 0000000..f9520da --- /dev/null +++ b/views/assets/js/class-tools.js @@ -0,0 +1,97 @@ +(function() { + function splitOnWhitespace(trigger) { + return trigger.split(/\s+/) + } + + function parseClassOperation(trimmedValue) { + var split = splitOnWhitespace(trimmedValue) + if (split.length > 1) { + var operation = split[0] + var classDef = split[1].trim() + var cssClass + var delay + if (classDef.indexOf(':') > 0) { + var splitCssClass = classDef.split(':') + cssClass = splitCssClass[0] + delay = htmx.parseInterval(splitCssClass[1]) + } else { + cssClass = classDef + delay = 100 + } + return { + operation, + cssClass, + delay + } + } else { + return null + } + } + + function performOperation(elt, classOperation, classList, currentRunTime) { + setTimeout(function() { + elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass) + }, currentRunTime) + } + + function toggleOperation(elt, classOperation, classList, currentRunTime) { + setTimeout(function() { + setInterval(function() { + elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass) + }, classOperation.delay) + }, currentRunTime) + } + + function processClassList(elt, classList) { + var runs = classList.split('&') + for (var i = 0; i < runs.length; i++) { + var run = runs[i] + var currentRunTime = 0 + var classOperations = run.split(',') + for (var j = 0; j < classOperations.length; j++) { + var value = classOperations[j] + var trimmedValue = value.trim() + var classOperation = parseClassOperation(trimmedValue) + if (classOperation) { + if (classOperation.operation === 'toggle') { + toggleOperation(elt, classOperation, classList, currentRunTime) + currentRunTime = currentRunTime + classOperation.delay + } else { + currentRunTime = currentRunTime + classOperation.delay + performOperation(elt, classOperation, classList, currentRunTime) + } + } + } + } + } + + function maybeProcessClasses(elt) { + if (elt.getAttribute) { + var classList = elt.getAttribute('classes') || elt.getAttribute('data-classes') + if (classList) { + processClassList(elt, classList) + } + } + } + + htmx.defineExtension('class-tools', { + onEvent: function(name, evt) { + if (name === 'htmx:afterProcessNode') { + var elt = evt.detail.elt + maybeProcessClasses(elt) + var classList = elt.getAttribute("apply-parent-classes") || elt.getAttribute("data-apply-parent-classes"); + if (classList) { + var parent = elt.parentElement; + parent.removeChild(elt); + parent.setAttribute("classes", classList); + maybeProcessClasses(parent); + } else if (elt.querySelectorAll) { + var children = elt.querySelectorAll('[classes], [data-classes]') + for (var i = 0; i < children.length; i++) { + maybeProcessClasses(children[i]) + } + } + } + } + }) +})() diff --git a/views/assets/js/client-side-templates.js b/views/assets/js/client-side-templates.js new file mode 100644 index 0000000..cf414ff --- /dev/null +++ b/views/assets/js/client-side-templates.js @@ -0,0 +1,96 @@ +htmx.defineExtension('client-side-templates', { + transformResponse: function(text, xhr, elt) { + var mustacheTemplate = htmx.closest(elt, '[mustache-template]') + if (mustacheTemplate) { + var data = JSON.parse(text) + var templateId = mustacheTemplate.getAttribute('mustache-template') + var template = htmx.find('#' + templateId) + if (template) { + return Mustache.render(template.innerHTML, data) + } else { + throw new Error('Unknown mustache template: ' + templateId) + } + } + + var mustacheArrayTemplate = htmx.closest(elt, '[mustache-array-template]') + if (mustacheArrayTemplate) { + var data = JSON.parse(text) + var templateId = mustacheArrayTemplate.getAttribute('mustache-array-template') + var template = htmx.find('#' + templateId) + if (template) { + return Mustache.render(template.innerHTML, { data }) + } else { + throw new Error('Unknown mustache template: ' + templateId) + } + } + + var handlebarsTemplate = htmx.closest(elt, '[handlebars-template]') + if (handlebarsTemplate) { + var data = JSON.parse(text) + var templateId = handlebarsTemplate.getAttribute('handlebars-template') + var templateElement = htmx.find('#' + templateId).innerHTML + var renderTemplate = Handlebars.compile(templateElement) + if (renderTemplate) { + return renderTemplate(data) + } else { + throw new Error('Unknown handlebars template: ' + templateId) + } + } + + var handlebarsArrayTemplate = htmx.closest(elt, '[handlebars-array-template]') + if (handlebarsArrayTemplate) { + var data = JSON.parse(text) + var templateId = handlebarsArrayTemplate.getAttribute('handlebars-array-template') + var templateElement = htmx.find('#' + templateId).innerHTML + var renderTemplate = Handlebars.compile(templateElement) + if (renderTemplate) { + return renderTemplate(data) + } else { + throw new Error('Unknown handlebars template: ' + templateId) + } + } + + var nunjucksTemplate = htmx.closest(elt, '[nunjucks-template]') + if (nunjucksTemplate) { + var data = JSON.parse(text) + var templateName = nunjucksTemplate.getAttribute('nunjucks-template') + var template = htmx.find('#' + templateName) + if (template) { + return nunjucks.renderString(template.innerHTML, data) + } else { + return nunjucks.render(templateName, data) + } + } + + var xsltTemplate = htmx.closest(elt, '[xslt-template]') + if (xsltTemplate) { + var templateId = xsltTemplate.getAttribute('xslt-template') + var template = htmx.find('#' + templateId) + if (template) { + var content = template.innerHTML + ? new DOMParser().parseFromString(template.innerHTML, 'application/xml') + : template.contentDocument + var processor = new XSLTProcessor() + processor.importStylesheet(content) + var data = new DOMParser().parseFromString(text, 'application/xml') + var frag = processor.transformToFragment(data, document) + return new XMLSerializer().serializeToString(frag) + } else { + throw new Error('Unknown XSLT template: ' + templateId) + } + } + + var nunjucksArrayTemplate = htmx.closest(elt, '[nunjucks-array-template]') + if (nunjucksArrayTemplate) { + var data = JSON.parse(text) + var templateName = nunjucksArrayTemplate.getAttribute('nunjucks-array-template') + var template = htmx.find('#' + templateName) + if (template) { + return nunjucks.renderString(template.innerHTML, { data }) + } else { + return nunjucks.render(templateName, { data }) + } + } + return text + } +}) diff --git a/views/assets/js/head-support.js b/views/assets/js/head-support.js new file mode 100644 index 0000000..b1e6878 --- /dev/null +++ b/views/assets/js/head-support.js @@ -0,0 +1,144 @@ +//========================================================== +// head-support.js +// +// An extension to add head tag merging. +//========================================================== +(function(){ + + var api = null; + + function log() { + //console.log(arguments); + } + + function mergeHead(newContent, defaultMergeStrategy) { + + if (newContent && newContent.indexOf(' -1) { + const htmlDoc = document.createElement("html"); + // remove svgs to avoid conflicts + var contentWithSvgsRemoved = newContent.replace(/]*>|>)([\s\S]*?)<\/svg>/gim, ''); + // extract head tag + var headTag = contentWithSvgsRemoved.match(/(]*>|>)([\s\S]*?)<\/head>)/im); + + // if the head tag exists... + if (headTag) { + + var added = [] + var removed = [] + var preserved = [] + var nodesToAppend = [] + + htmlDoc.innerHTML = headTag; + var newHeadTag = htmlDoc.querySelector("head"); + var currentHead = document.head; + + if (newHeadTag == null) { + return; + } else { + // put all new head elements into a Map, by their outerHTML + var srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHeadTag.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + } + + + + // determine merge strategy + var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy; + + // get the current head + for (const currentHeadElt of currentHead.children) { + + // If the current head element is in the map + var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval"; + var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true"; + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (mergeStrategy === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the tremaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + log("to append: ", nodesToAppend); + + for (const newNode of nodesToAppend) { + log("adding: ", newNode); + var newElt = document.createRange().createContextualFragment(newNode.outerHTML); + log(newElt); + if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) { + currentHead.appendChild(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) { + currentHead.removeChild(removedElement); + } + } + + api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed}); + } + } + } + + htmx.defineExtension("head-support", { + init: function(apiRef) { + // store a reference to the internal API. + api = apiRef; + + htmx.on('htmx:afterSwap', function(evt){ + let xhr = evt.detail.xhr; + if (xhr) { + var serverResponse = xhr.response; + if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) { + mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append"); + } + } + }) + + htmx.on('htmx:historyRestore', function(evt){ + if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) { + if (evt.detail.cacheMiss) { + mergeHead(evt.detail.serverResponse, "merge"); + } else { + mergeHead(evt.detail.item.head, "merge"); + } + } + }) + + htmx.on('htmx:historyItemCreated', function(evt){ + var historyItem = evt.detail.item; + historyItem.head = document.head.outerHTML; + }) + } + }); + +})() diff --git a/views/assets/js/include-vals.js b/views/assets/js/include-vals.js new file mode 100644 index 0000000..335ba13 --- /dev/null +++ b/views/assets/js/include-vals.js @@ -0,0 +1,23 @@ +(function() { + function mergeObjects(obj1, obj2) { + for (var key in obj2) { + if (obj2.hasOwnProperty(key)) { + obj1[key] = obj2[key] + } + } + return obj1 + } + + htmx.defineExtension('include-vals', { + onEvent: function(name, evt) { + if (name === 'htmx:configRequest') { + var includeValsElt = htmx.closest(evt.detail.elt, '[include-vals],[data-include-vals]') + if (includeValsElt) { + var includeVals = includeValsElt.getAttribute('include-vals') || includeValsElt.getAttribute('data-include-vals') + var valuesToInclude = eval('({' + includeVals + '})') + mergeObjects(evt.detail.parameters, valuesToInclude) + } + } + } + }) +})() diff --git a/views/assets/js/loading-states.js b/views/assets/js/loading-states.js new file mode 100644 index 0000000..e42bb52 --- /dev/null +++ b/views/assets/js/loading-states.js @@ -0,0 +1,184 @@ +;(function() { + const loadingStatesUndoQueue = [] + + function loadingStateContainer(target) { + return htmx.closest(target, '[data-loading-states]') || document.body + } + + function mayProcessUndoCallback(target, callback) { + if (document.body.contains(target)) { + callback() + } + } + + function mayProcessLoadingStateByPath(elt, requestPath) { + const pathElt = htmx.closest(elt, '[data-loading-path]') + if (!pathElt) { + return true + } + + return pathElt.getAttribute('data-loading-path') === requestPath + } + + function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) { + const delayElt = htmx.closest(sourceElt, '[data-loading-delay]') + if (delayElt) { + const delayInMilliseconds = + delayElt.getAttribute('data-loading-delay') || 200 + const timeout = setTimeout(function() { + doCallback() + + loadingStatesUndoQueue.push(function() { + mayProcessUndoCallback(targetElt, undoCallback) + }) + }, delayInMilliseconds) + + loadingStatesUndoQueue.push(function() { + mayProcessUndoCallback(targetElt, function() { clearTimeout(timeout) }) + }) + } else { + doCallback() + loadingStatesUndoQueue.push(function() { + mayProcessUndoCallback(targetElt, undoCallback) + }) + } + } + + function getLoadingStateElts(loadingScope, type, path) { + return Array.from(htmx.findAll(loadingScope, '[' + type + ']')).filter( + function(elt) { return mayProcessLoadingStateByPath(elt, path) } + ) + } + + function getLoadingTarget(elt) { + if (elt.getAttribute('data-loading-target')) { + return Array.from( + htmx.findAll(elt.getAttribute('data-loading-target')) + ) + } + return [elt] + } + + htmx.defineExtension('loading-states', { + onEvent: function(name, evt) { + if (name === 'htmx:beforeRequest') { + const container = loadingStateContainer(evt.target) + + const loadingStateTypes = [ + 'data-loading', + 'data-loading-class', + 'data-loading-class-remove', + 'data-loading-disable', + 'data-loading-aria-busy' + ] + + const loadingStateEltsByType = {} + + loadingStateTypes.forEach(function(type) { + loadingStateEltsByType[type] = getLoadingStateElts( + container, + type, + evt.detail.pathInfo.requestPath + ) + }) + + loadingStateEltsByType['data-loading'].forEach(function(sourceElt) { + getLoadingTarget(sourceElt).forEach(function(targetElt) { + queueLoadingState( + sourceElt, + targetElt, + function() { + targetElt.style.display = + sourceElt.getAttribute('data-loading') || + 'inline-block' + }, + function() { targetElt.style.display = 'none' } + ) + }) + }) + + loadingStateEltsByType['data-loading-class'].forEach( + function(sourceElt) { + const classNames = sourceElt + .getAttribute('data-loading-class') + .split(' ') + + getLoadingTarget(sourceElt).forEach(function(targetElt) { + queueLoadingState( + sourceElt, + targetElt, + function() { + classNames.forEach(function(className) { + targetElt.classList.add(className) + }) + }, + function() { + classNames.forEach(function(className) { + targetElt.classList.remove(className) + }) + } + ) + }) + } + ) + + loadingStateEltsByType['data-loading-class-remove'].forEach( + function(sourceElt) { + const classNames = sourceElt + .getAttribute('data-loading-class-remove') + .split(' ') + + getLoadingTarget(sourceElt).forEach(function(targetElt) { + queueLoadingState( + sourceElt, + targetElt, + function() { + classNames.forEach(function(className) { + targetElt.classList.remove(className) + }) + }, + function() { + classNames.forEach(function(className) { + targetElt.classList.add(className) + }) + } + ) + }) + } + ) + + loadingStateEltsByType['data-loading-disable'].forEach( + function(sourceElt) { + getLoadingTarget(sourceElt).forEach(function(targetElt) { + queueLoadingState( + sourceElt, + targetElt, + function() { targetElt.disabled = true }, + function() { targetElt.disabled = false } + ) + }) + } + ) + + loadingStateEltsByType['data-loading-aria-busy'].forEach( + function(sourceElt) { + getLoadingTarget(sourceElt).forEach(function(targetElt) { + queueLoadingState( + sourceElt, + targetElt, + function() { targetElt.setAttribute('aria-busy', 'true') }, + function() { targetElt.removeAttribute('aria-busy') } + ) + }) + } + ) + } + + if (name === 'htmx:beforeOnLoad') { + while (loadingStatesUndoQueue.length > 0) { + loadingStatesUndoQueue.shift()() + } + } + } + }) +})() diff --git a/views/assets/js/multi-swap.js b/views/assets/js/multi-swap.js new file mode 100644 index 0000000..4d3f9f5 --- /dev/null +++ b/views/assets/js/multi-swap.js @@ -0,0 +1,44 @@ +(function() { + /** @type {import("../htmx").HtmxInternalApi} */ + var api + + htmx.defineExtension('multi-swap', { + init: function(apiRef) { + api = apiRef + }, + isInlineSwap: function(swapStyle) { + return swapStyle.indexOf('multi:') === 0 + }, + handleSwap: function(swapStyle, target, fragment, settleInfo) { + if (swapStyle.indexOf('multi:') === 0) { + var selectorToSwapStyle = {} + var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/) + + elements.forEach(function(element) { + var split = element.split(/\s*:\s*/) + var elementSelector = split[0] + var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML' + + if (elementSelector.charAt(0) !== '#') { + console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.") + return + } + + selectorToSwapStyle[elementSelector] = elementSwapStyle + }) + + for (var selector in selectorToSwapStyle) { + var swapStyle = selectorToSwapStyle[selector] + var elementToSwap = fragment.querySelector(selector) + if (elementToSwap) { + api.oobSwap(swapStyle, elementToSwap, settleInfo) + } else { + console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.") + } + } + + return true + } + } + }) +})() diff --git a/views/assets/js/path-params.js b/views/assets/js/path-params.js new file mode 100644 index 0000000..0c65d84 --- /dev/null +++ b/views/assets/js/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) + }) + } + } +}) diff --git a/views/assets/js/preload.js b/views/assets/js/preload.js new file mode 100644 index 0000000..3bb56a1 --- /dev/null +++ b/views/assets/js/preload.js @@ -0,0 +1,388 @@ +(function() { + /** + * This adds the "preload" extension to htmx. The extension will + * preload the targets of elements with "preload" attribute if: + * - they also have `href`, `hx-get` or `data-hx-get` attributes + * - they are radio buttons, checkboxes, select elements and submit + * buttons of forms with `method="get"` or `hx-get` attributes + * The extension relies on browser cache and for it to work + * server response must include `Cache-Control` header + * e.g. `Cache-Control: private, max-age=60`. + * For more details @see https://htmx.org/extensions/preload/ + */ + + htmx.defineExtension('preload', { + onEvent: function(name, event) { + // Process preload attributes on `htmx:afterProcessNode` + if (name === 'htmx:afterProcessNode') { + // Initialize all nodes with `preload` attribute + const parent = event.target || event.detail.elt; + const preloadNodes = [ + ...parent.hasAttribute("preload") ? [parent] : [], + ...parent.querySelectorAll("[preload]")] + preloadNodes.forEach(function(node) { + // Initialize the node with the `preload` attribute + init(node) + + // Initialize all child elements which has + // `href`, `hx-get` or `data-hx-get` attributes + node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init) + }) + return + } + + // Intercept HTMX preload requests on `htmx:beforeRequest` and + // send them as XHR requests instead to avoid side-effects, + // such as showing loading indicators while preloading data. + if (name === 'htmx:beforeRequest') { + const requestHeaders = event.detail.requestConfig.headers + if (!("HX-Preloaded" in requestHeaders + && requestHeaders["HX-Preloaded"] === "true")) { + return + } + + event.preventDefault() + // Reuse XHR created by HTMX with replaced callbacks + const xhr = event.detail.xhr + xhr.onload = function() { + processResponse(event.detail.elt, xhr.responseText) + } + xhr.onerror = null + xhr.onabort = null + xhr.ontimeout = null + xhr.send() + } + } + }) + + /** + * Initialize `node`, set up event handlers based on own or inherited + * `preload` attributes and set `node.preloadState` to `READY`. + * + * `node.preloadState` can have these values: + * - `READY` - event handlers have been set up and node is ready to preload + * - `TIMEOUT` - a triggering event has been fired, but `node` is not + * yet being loaded because some time need to pass first e.g. user + * has to keep hovering over an element for 100ms for preload to start + * - `LOADING` means that `node` is in the process of being preloaded + * - `DONE` means that the preloading process is complete and `node` + * doesn't need a repeated preload (indicated by preload="always") + * @param {Node} node + */ + function init(node) { + // Guarantee that each node is initialized only once + if (node.preloadState !== undefined) { + return + } + + if (!isValidNodeForPreloading(node)) { + return + } + + // Initialize form element preloading + if (node instanceof HTMLFormElement) { + const form = node + // Only initialize forms with `method="get"` or `hx-get` attributes + if (!((form.hasAttribute('method') && form.method === 'get') + || form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) { + return + } + for (let i = 0; i < form.elements.length; i++) { + const element = form.elements.item(i); + init(element); + element.labels.forEach(init); + } + return + } + + // Process node configuration from preload attribute + let preloadAttr = getClosestAttribute(node, 'preload'); + node.preloadAlways = preloadAttr && preloadAttr.includes('always'); + if (node.preloadAlways) { + preloadAttr = preloadAttr.replace('always', '').trim(); + } + let triggerEventName = preloadAttr || 'mousedown'; + + // Set up event handlers listening for triggering events + const needsTimeout = triggerEventName === 'mouseover' + node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout)) + + // Add `touchstart` listener for touchscreen support + // if `mousedown` or `mouseover` is used + if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') { + node.addEventListener('touchstart', getEventHandler(node)) + } + + // If `mouseover` is used, set up `mouseout` listener, + // which will abort preloading if user moves mouse outside + // the element in less than 100ms after hovering over it + if (triggerEventName === 'mouseover') { + node.addEventListener('mouseout', function(evt) { + if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) { + node.preloadState = 'READY' + } + }) + } + + // Mark the node as ready to be preloaded + node.preloadState = 'READY' + + // This event can be used to load content immediately + htmx.trigger(node, 'preload:init') + } + + /** + * Return event handler which can be called by event listener to start + * the preloading process of `node` with or without a timeout + * @param {Node} node + * @param {boolean=} needsTimeout + * @returns {function(): void} + */ + function getEventHandler(node, needsTimeout = false) { + return function() { + // Do not preload uninitialized nodes, nodes which are in process + // of being preloaded or have been preloaded and don't need repeat + if (node.preloadState !== 'READY') { + return + } + + if (needsTimeout) { + node.preloadState = 'TIMEOUT' + const timeoutMs = 100 + window.setTimeout(function() { + if (node.preloadState === 'TIMEOUT') { + node.preloadState = 'READY' + load(node) + } + }, timeoutMs) + return + } + + load(node) + } + } + + /** + * Preload the target of node, which can be: + * - hx-get or data-hx-get attribute + * - href or form action attribute + * @param {Node} node + */ + function load(node) { + // Do not preload uninitialized nodes, nodes which are in process + // of being preloaded or have been preloaded and don't need repeat + if (node.preloadState !== 'READY') { + return + } + node.preloadState = 'LOADING' + + // Load nodes with `hx-get` or `data-hx-get` attribute + // Forms don't reach this because only their elements are initialized + const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get') + if (hxGet) { + sendHxGetRequest(hxGet, node); + return + } + + // Load nodes with `href` attribute + const hxBoost = getClosestAttribute(node, "hx-boost") === "true" + if (node.hasAttribute('href')) { + const url = node.getAttribute('href'); + if (hxBoost) { + sendHxGetRequest(url, node); + } else { + sendXmlGetRequest(url, node); + } + return + } + + // Load form elements + if (isPreloadableFormElement(node)) { + const url = node.form.getAttribute('action') + || node.form.getAttribute('hx-get') + || node.form.getAttribute('data-hx-get'); + const formData = htmx.values(node.form); + const isStandardForm = !(node.form.getAttribute('hx-get') + || node.form.getAttribute('data-hx-get') + || hxBoost); + const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest + + // submit button + if (node.type === 'submit') { + sendGetRequest(url, node.form, formData) + return + } + + // select + const inputName = node.name || node.control.name; + if (node.tagName === 'SELECT') { + Array.from(node.options).forEach(option => { + if (option.selected) return; + formData.set(inputName, option.value); + const formDataOrdered = forceFormDataInOrder(node.form, formData); + sendGetRequest(url, node.form, formDataOrdered) + }); + return + } + + // radio and checkbox + const inputType = node.getAttribute("type") || node.control.getAttribute("type"); + const nodeValue = node.value || node.control?.value; + if (inputType === 'radio') { + formData.set(inputName, nodeValue); + } else if (inputType === 'checkbox'){ + const inputValues = formData.getAll(inputName); + if (inputValues.includes(nodeValue)) { + formData[inputName] = inputValues.filter(value => value !== nodeValue); + } else { + formData.append(inputName, nodeValue); + } + } + const formDataOrdered = forceFormDataInOrder(node.form, formData); + sendGetRequest(url, node.form, formDataOrdered) + return + } + } + + /** + * Force formData values to be in the order of form elements. + * This is useful to apply after alternating formData values + * and before passing them to a HTTP request because cache is + * sensitive to GET parameter order e.g., cached `/link?a=1&b=2` + * will not be used for `/link?b=2&a=1`. + * @param {HTMLFormElement} form + * @param {FormData} formData + * @returns {FormData} + */ + function forceFormDataInOrder(form, formData) { + const formElements = form.elements; + const orderedFormData = new FormData(); + for(let i = 0; i < formElements.length; i++) { + const element = formElements.item(i); + if (formData.has(element.name) && element.tagName === 'SELECT') { + orderedFormData.append( + element.name, formData.get(element.name)); + continue; + } + if (formData.has(element.name) && formData.getAll(element.name) + .includes(element.value)) { + orderedFormData.append(element.name, element.value); + } + } + return orderedFormData; + } + + /** + * Send GET request with `hx-request` headers as if `sourceNode` + * target was loaded. Send alternated values if `formData` is set. + * + * Note that this request is intercepted and sent as XMLHttpRequest. + * It is necessary to use `htmx.ajax` to acquire correct headers which + * HTMX and extensions add based on `sourceNode`. But it cannot be used + * to perform the request due to side-effects e.g. loading indicators. + * @param {string} url + * @param {Node} sourceNode + * @param {FormData=} formData + */ + function sendHxGetRequest(url, sourceNode, formData = undefined) { + htmx.ajax('GET', url, { + source: sourceNode, + values: formData, + headers: {"HX-Preloaded": "true"} + }); + } + + /** + * Send XML GET request to `url`. Send `formData` as URL params if set. + * @param {string} url + * @param {Node} sourceNode + * @param {FormData=} formData + */ + function sendXmlGetRequest(url, sourceNode, formData = undefined) { + const xhr = new XMLHttpRequest() + if (formData) { + url += '?' + new URLSearchParams(formData.entries()).toString() + } + xhr.open('GET', url); + xhr.setRequestHeader("HX-Preloaded", "true") + xhr.onload = function() { processResponse(sourceNode, xhr.responseText) } + xhr.send() + } + + /** + * Process request response by marking node `DONE` to prevent repeated + * requests, except if preload attribute contains `always`, + * and load linked resources (e.g. images) returned in the response + * if `preload-images` attribute is `true` + * @param {Node} node + * @param {string} responseText + */ + function processResponse(node, responseText) { + node.preloadState = node.preloadAlways ? 'READY' : 'DONE' + + if (getClosestAttribute(node, 'preload-images') === 'true') { + // Load linked resources + document.createElement('div').innerHTML = responseText + } + } + + /** + * Gets attribute value from node or one of its parents + * @param {Node} node + * @param {string} attribute + * @returns { string | undefined } + */ + function getClosestAttribute(node, attribute) { + if (node == undefined) { return undefined } + return node.getAttribute(attribute) + || node.getAttribute('data-' + attribute) + || getClosestAttribute(node.parentElement, attribute) + } + + /** + * Determines if node is valid for preloading and should be + * initialized by setting up event listeners and handlers + * @param {Node} node + * @returns {boolean} + */ + function isValidNodeForPreloading(node) { + // Add listeners only to nodes which include "GET" transactions + // or preloadable "GET" form elements + const getReqAttrs = ['href', 'hx-get', 'data-hx-get']; + const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a)) + || node.method === 'get'; + const isPreloadableGetFormElement = node.form instanceof HTMLFormElement + && includesGetRequest(node.form) + && isPreloadableFormElement(node) + if (!includesGetRequest(node) && !isPreloadableGetFormElement) { + return false + } + + // Don't preload elements contained in