test reorg and docs

This commit is contained in:
Carson Gross
2025-11-06 11:30:55 -07:00
parent 30ff306c31
commit 3858a82242
23 changed files with 234 additions and 366 deletions

View File

@@ -1,35 +1,46 @@
# htmx Coding Standards
* Prefer `for` loop to `forEach` (easier to debug, compresses better)
* Assign complex expressions to a local variable rather than using them directly in statements (easier to debug)
* Private methods should be prefixed with `__`. The `dist` task will replace double underscore with `#` when it builds
the final script. This allows us to unit test private methods.
* "Internal" methods should be prefixed with a `_`. These methods are _not_ guaranteed to never change, but may be useful
for special cases (e.g. the `quirks` htmx 2.0 compatibility extension)
* Public methods are forever, be very careful with them
* Use `let` rather than `const`
* Publicly surfaced properties should not be shortened, _except_ "Configuration" which can be shortened to "Config"
* Local variables should have descriptive names in most cases. `ctx` and `elt` are acceptable.
* Terser does a good job of minimizing names, so there is no benefit from a size perspective to using short variable names.
* Generally all state in the trigger -> request -> swap life cycle should be stored on `ctx`. Try to avoid overwriting
an existing property, pick a new property name. These properties are part of the public API and *must* be documented.
* There is size code benefit to naked if statements, use curlies to make debugging easier:
```js
// terser turns these two forms into the same compressed code
if(bool) return;
if(bool) {
return;
}
```
* Please lets keep this thing under 10k please please please
* General Code Style
* Prefer `for` loop to `forEach` (easier to debug, compresses better)
* Assign complex expressions to a local variable rather than using them directly in statements (easier to debug)
* Use `let` rather than `const`
* Local variables should have descriptive names in most cases. `ctx` and `elt` are acceptable.
* Terser does a good job of minimizing names, so there is no benefit from a size perspective to using short variable names.
* There is no size code benefit to naked if statements, use curlies to make debugging easier:
```js
// terser turns these two forms into the same compressed code
if(bool) return;
if(bool) {
return;
}
```
* Method/Field Conventions
* Private methods should be prefixed with `__`. The `dist` task will replace double underscore with `#` when it builds
the final script. This allows us to unit test private methods.
* "Internal" methods should be prefixed with a `_`. These methods are _not_ guaranteed to never change, but may be useful
for special cases (e.g. the `quirks` htmx 2.0 compatibility extension)
* Public methods are forever, be very careful with them
* Publicly surfaced properties should not be shortened, _except_ "Configuration" which can be shortened to "Config"
* Architectural Style
* Generally all state in the trigger -> request -> swap life cycle should be stored on `ctx`. Try to avoid overwriting
an existing property, pick a new property name. These properties are part of the public API and *must* be documented.
## Testing
* TODO - outline testing standards
Tests for htmx are organized in the following manner:
* `/test/unit` - These tests should for the most part *directly* exercise public and private methods. Because in
dev private methods are just public methods that start with `__` this is easy to do. Unit tests should be created
after a method has stabilized and the behavior is reasonably well understood.
* `/test/attributes` - These are integration tests that test the full behavior of a given attribute and should do things
like set up a response mock using `mockResponse()`, create a live HTML button with the `createProcessedHTML` method,
invoke `click()` on the button, await the `"htmx:finally:request" event, and assert something about the updated DOM.
* `/test/end2end` - These are end-to-end tests that do not fit in the other two categories
## AI Policy
AI may not be used to generate any significant amount of code that is added to htmx.js. It may be used to _suggest_ code,
AI may _not_ be used to generate any significant amount of code that is added to htmx.js. It may be used to _suggest_ code,
but that code must be audited and every line understood by the author.
AI _may_ be used to generate tests for htmx. These tests should follow the existing standards as much as possible and

178
dist/htmx.esm.js vendored
View File

@@ -72,7 +72,7 @@ var htmx = (() => {
this.#initHtmxConfig();
this.#initRequestIndicatorCss();
this.#actionSelector = `[${this.#prefix("hx-action")}],[${this.#prefix("hx-get")}],[${this.#prefix("hx-post")}],[${this.#prefix("hx-put")}],[${this.#prefix("hx-patch")}],[${this.#prefix("hx-delete")}]`;
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[ starts-with(name(), "${this.#prefix("hx-on:")}")]]`);
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[ starts-with(name(), "${this.#prefix("hx-on")}")]]`);
this.#internalAPI = {
attributeValue: this.#attributeValue.bind(this),
parseTriggerSpecs: this.#parseTriggerSpecs.bind(this),
@@ -110,6 +110,7 @@ var htmx = (() => {
},
morphIgnore: ["data-htmx-powered"],
noSwap: [204],
implicitInheritance: false
}
let metaConfig = document.querySelector('meta[name="htmx:config"]');
if (metaConfig) {
@@ -176,29 +177,41 @@ var htmx = (() => {
#attributeValue(elt, name, defaultVal) {
name = this.#prefix(name);
let appendName = name + ":append";
let inheritName = name + ":inherited";
let inheritAppendName = name + ":inherited:append";
let appendName = name + this.#maybeAdjustMetaCharacter(":append");
let inheritName = name + (this.config.implicitInheritance ? "" : this.#maybeAdjustMetaCharacter(":inherited"));
let inheritAppendName = name + this.#maybeAdjustMetaCharacter(":inherited:append");
if (elt.hasAttribute(name) || elt.hasAttribute(inheritName)) {
return elt.getAttribute(name) || elt.getAttribute(inheritName);
if (elt.hasAttribute(name)) {
return{ val: elt.getAttribute(name), src: elt };
}
if (elt.hasAttribute(inheritName)) {
return{ val: elt.getAttribute(inheritName), src: elt };
}
if (elt.hasAttribute(appendName) || elt.hasAttribute(inheritAppendName)) {
let appendValue = elt.getAttribute(appendName) || elt.getAttribute(inheritAppendName);
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
if (parent) {
let inheritedValue = this.#attributeValue(parent, name);
return inheritedValue ? inheritedValue + "," + appendValue : appendValue;
let inherited = this.#attributeValue(parent, name);
return {
val: inherited.val ? inherited.val + "," + appendValue : appendValue,
src: elt
};
} else {
return {val: appendValue, src: elt};
}
return appendValue;
}
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
if (parent) {
return this.#attributeValue(parent, name);
let valAndSrc = this.#attributeValue(parent, name);
if (valAndSrc && this.config.implicitInheritance) {
this.#trigger(elt, "htmx:after:implicitInheritance", {elt, parent})
}
return valAndSrc;
}
return defaultVal;
return{ val: defaultVal, src: elt };
}
#tokenize(str) {
@@ -263,13 +276,14 @@ var htmx = (() => {
if (this.#isBoosted(elt)) {
return this.#boostedMethodAndAction(elt, evt)
} else {
let method = this.#attributeValue(elt, "hx-method") || "get";
let action = this.#attributeValue(elt, "hx-action");
let valueAndElt = this.#attributeValue(elt, "hx-method")
let method = valueAndElt.val || "GET"
let {val: action} = this.#attributeValue(elt, "hx-action") || {};
if (!action) {
for (let verb of this.#verbs) {
let verbAttribute = this.#attributeValue(elt, "hx-" + verb);
if (verbAttribute) {
action = verbAttribute;
let {val: verbAction} = this.#attributeValue(elt, "hx-" + verb) || {};
if (verbAction) {
action = verbAction;
method = verb;
break;
}
@@ -319,15 +333,15 @@ var htmx = (() => {
sourceElement,
sourceEvent,
status: "created",
select: this.#attributeValue(sourceElement, "hx-select"),
selectOOB: this.#attributeValue(sourceElement, "hx-select-oob"),
target: this.#attributeValue(sourceElement, "hx-target"),
swap: this.#attributeValue(sourceElement, "hx-swap", this.config.defaultSwap),
push: this.#attributeValue(sourceElement, "hx-push-url"),
replace: this.#attributeValue(sourceElement, "hx-replace-url"),
select: this.#attributeValue(sourceElement, "hx-select")?.val,
selectOOB: this.#attributeValue(sourceElement, "hx-select-oob")?.val,
target: this.#attributeValue(sourceElement, "hx-target")?.val,
swap: this.#attributeValue(sourceElement, "hx-swap", this.config.defaultSwap)?.val,
push: this.#attributeValue(sourceElement, "hx-push-url")?.val,
replace: this.#attributeValue(sourceElement, "hx-replace-url")?.val,
transition: this.config.transitions,
request: {
validate: "true" === this.#attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false"),
validate: "true" === this.#attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false")?.val,
action,
method,
headers: this.#determineHeaders(sourceElement)
@@ -335,7 +349,7 @@ var htmx = (() => {
};
// Apply hx-config overrides
let configAttr = this.#attributeValue(sourceElement, "hx-config");
let {val: configAttr} = this.#attributeValue(sourceElement, "hx-config") || {};
if (configAttr) {
let configOverrides = JSON.parse(configAttr);
let requestConfig = ctx.request;
@@ -364,7 +378,7 @@ var htmx = (() => {
if (this.#isBoosted(elt)) {
headers["HX-Boosted"] = "true"
}
let headersAttribute = this.#attributeValue(elt, "hx-headers");
let {val: headersAttribute} = this.#attributeValue(elt, "hx-headers") || {};
if (headersAttribute) {
Object.assign(headers, JSON.parse(headersAttribute));
}
@@ -375,11 +389,7 @@ var htmx = (() => {
if (selector instanceof Element) {
return selector;
} else if (selector === 'this') {
if (elt.hasAttribute(this.#prefix("hx-target"))) {
return elt;
} else {
return elt.closest(`[${this.#prefix("hx-target")}\\:inherited='this']`)
}
return this.#attributeValue(elt, "hx-target").src
} else if (selector != null) {
return this.find(elt, selector);
} else if (this.#isBoosted(elt)) {
@@ -393,7 +403,7 @@ var htmx = (() => {
return elt._htmx?.boosted;
}
async __handleTriggerEvent(ctx) {
async #handleTriggerEvent(ctx) {
let elt = ctx.sourceElement
let evt = ctx.sourceEvent
if (!elt.isConnected) return
@@ -419,7 +429,7 @@ var htmx = (() => {
// Setup abort controller and action
let ac = new AbortController()
let action = ctx.request.action.replace?.(/#.*$/, '')
// TODO - consider how this works with hx-config, move most to __createRequestContext?
// TODO - consider how this works with hx-config, move most to #createRequestContext?
Object.assign(ctx.request, {
originalAction: ctx.request.action,
action,
@@ -445,14 +455,14 @@ var htmx = (() => {
let params = new URLSearchParams(ctx.request.body);
if (params.size) ctx.request.action += (/\?/.test(ctx.request.action) ? "&" : "?") + params
ctx.request.body = null
} else if (this.#attributeValue(elt, "hx-encoding") !== "multipart/form-data") {
} else if (this.#attributeValue(elt, "hx-encoding")?.val !== "multipart/form-data") {
ctx.request.body = new URLSearchParams(ctx.request.body);
}
await this.#issueRequest(ctx);
}
async __issueRequest(ctx) {
async #issueRequest(ctx) {
let elt = ctx.sourceElement
let syncStrategy = this.#determineSyncStrategy(elt);
let requestQueue = this.#getRequestQueue(elt);
@@ -462,14 +472,14 @@ var htmx = (() => {
ctx.status = "issuing"
this.#initTimeout(ctx);
let indicatorsSelector = this.#attributeValue(elt, "hx-indicator");
let {val: indicatorsSelector} = this.#attributeValue(elt, "hx-indicator") || {};
let indicators = this.#showIndicators(elt, indicatorsSelector);
let disableSelector = this.#attributeValue(elt, "hx-disable");
let {val: disableSelector} = this.#attributeValue(elt, "hx-disable") || {};
let disableElements = this.#disableElements(elt, disableSelector);
try {
// Confirm dialog
let confirmVal = this.#attributeValue(elt, 'hx-confirm')
let {val: confirmVal} = this.#attributeValue(elt, 'hx-confirm') || {};
if (confirmVal) {
let js = this.#extractJavascriptContent(confirmVal);
if (js) {
@@ -572,7 +582,7 @@ var htmx = (() => {
}
}
async __handleSSE(ctx, elt, response) {
async #handleSSE(ctx, elt, response) {
let config = elt._htmx?.streamConfig || {...this.config.streams};
let waitForVisible = () => new Promise(r => {
@@ -669,7 +679,7 @@ var htmx = (() => {
}
}
async* __parseSSE(res) {
async* #parseSSE(res) {
let r = res.body.getReader(), d = new TextDecoder(), b = '', m = {data: '', event: '', id: '', retry: null},
ls, i, n, f, v;
try {
@@ -700,12 +710,12 @@ var htmx = (() => {
}
#determineSyncStrategy(elt) {
let syncValue = this.#attributeValue(elt, "hx-sync");
let {val: syncValue} = this.#attributeValue(elt, "hx-sync") || {};
return syncValue?.split(":")[1] || "queue first";
}
#getRequestQueue(elt) {
let syncValue = this.#attributeValue(elt, "hx-sync");
let {val: syncValue} = this.#attributeValue(elt, "hx-sync") || {};
let syncElt = elt
if (syncValue && syncValue.includes(":")) {
let strings = syncValue.split(":");
@@ -742,7 +752,7 @@ var htmx = (() => {
}
#initializeTriggers(elt, initialHandler = elt._htmx.eventHandler) {
let specString = this.#attributeValue(elt, "hx-trigger");
let {val: specString} = this.#attributeValue(elt, "hx-trigger") || {};
if (!specString) {
specString = elt.matches("form") ? "submit" :
elt.matches("input:not([type=button]),select,textarea") ? "change" :
@@ -892,7 +902,7 @@ var htmx = (() => {
}
#initializeStreamConfig(elt) {
let streamSpec = this.#attributeValue(elt, 'hx-stream');
let {val: streamSpec} = this.#attributeValue(elt, 'hx-stream') || {};
if (!streamSpec) return;
// Start with global defaults
@@ -961,7 +971,7 @@ var htmx = (() => {
return bound;
}
async __executeJavaScriptAsync(thisArg, obj, code, expression = true) {
async #executeJavaScriptAsync(thisArg, obj, code, expression = true) {
let args = {}
Object.assign(args, this.#apiMethods(thisArg))
Object.assign(args, obj)
@@ -1002,7 +1012,7 @@ var htmx = (() => {
}
#maybeBoost(elt) {
if (this.#attributeValue(elt, "hx-boost") === "true") {
if (this.#attributeValue(elt, "hx-boost")?.val === "true") {
if (this.#shouldInitialize(elt)) {
elt._htmx = {eventHandler: this.#createHtmxEventHandler(elt), requests: [], boosted: true}
elt.setAttribute('data-htmx-powered', 'true');
@@ -1077,7 +1087,8 @@ var htmx = (() => {
#makeFragment(text) {
let response = text.replace(/<partial(\s+|>)/gi, '<template partial$1').replace(/<\/partial>/gi, '</template>');
// TODO - store any head tag content on the fragment for head extension
let title = '';
response = response.replace(/<title[^>]*>([\s\S]*?)<\/title>/i, (m, t) => (title = t, ''));
let responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '');
let startTag = responseWithNoHead.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();
@@ -1092,10 +1103,11 @@ var htmx = (() => {
doc = this.#parseHTML(`<template>${responseWithNoHead}</template>`);
fragment = doc.querySelector('template').content;
}
this.#processScripts(fragment);
return {
fragment,
title: doc.title
title
};
}
@@ -1114,18 +1126,9 @@ var htmx = (() => {
let swapSpec = this.#parseSwapSpec(oobValue);
if (swapSpec.target) target = swapSpec.target;
let oobElementClone = elt.cloneNode(true);
let fragment;
if (swapSpec.strip === undefined && swapSpec.style !== 'outerHTML') {
swapSpec.strip = true;
}
if (swapSpec.strip) {
fragment = oobElementClone.content || oobElementClone;
} else {
fragment = document.createDocumentFragment();
fragment.appendChild(oobElementClone);
}
elt.remove();
swapSpec.strip ??= !swapSpec.style.startsWith('outer');
let fragment = document.createDocumentFragment();
fragment.append(elt);
if (!target && !oobValue.includes('target:')) return;
tasks.push({
@@ -1255,6 +1258,7 @@ var htmx = (() => {
async swap(ctx) {
let {fragment, title} = this.#makeFragment(ctx.text);
ctx.title = title;
let tasks = [];
// Process OOB and partials
@@ -1263,7 +1267,7 @@ var htmx = (() => {
tasks.push(...oobTasks, ...partialTasks);
// Process main swap
let mainSwap = this.#processMainSwap(ctx, fragment, partialTasks, title);
let mainSwap = this.#processMainSwap(ctx, fragment, partialTasks);
if (mainSwap) {
tasks.push(mainSwap);
}
@@ -1299,7 +1303,7 @@ var htmx = (() => {
}
this.#trigger(document, "htmx:after:swap", {ctx});
if (mainSwap?.title) document.title = mainSwap.title;
if (ctx.title && !mainSwap?.swapSpec?.ignoreTitle) document.title = ctx.title;
await this.timeout(1);
// invoke restore tasks
for (let task of tasks) {
@@ -1312,33 +1316,24 @@ var htmx = (() => {
// if (ctx.hx?.triggerafterswap) this.#handleTriggerHeader(ctx.hx.triggerafterswap, ctx.sourceElement);
}
#processMainSwap(ctx, fragment, partialTasks, title) {
#processMainSwap(ctx, fragment, partialTasks) {
// Create main task if needed
let swapSpec = this.#parseSwapSpec(ctx.swap || this.config.defaultSwap);
// skip creating main swap if extracting partials resulted in empty response except for delete style
if (swapSpec.style === 'delete' || /\S/.test(fragment.innerHTML || '') || !partialTasks.length) {
let resultFragment = document.createDocumentFragment();
if (ctx.select) {
let selected = fragment.querySelector(ctx.select);
if (selected) {
if (swapSpec.strip === false) {
resultFragment.append(selected);
} else {
resultFragment.append(...selected.childNodes);
}
}
} else {
resultFragment.append(...fragment.childNodes);
let selected = fragment.querySelectorAll(ctx.select);
fragment = document.createDocumentFragment();
fragment.append(...selected);
}
let mainSwap = {
type: 'main',
fragment: resultFragment,
fragment,
target: swapSpec.target || ctx.target,
swapSpec,
sourceElement: ctx.sourceElement,
transition: (ctx.transition !== false) && (swapSpec.transition !== false),
title
transition: (ctx.transition !== false) && (swapSpec.transition !== false)
};
return mainSwap;
}
@@ -1350,8 +1345,13 @@ var htmx = (() => {
target = document.querySelector(target);
}
if (!target) return;
if (swapSpec.strip && fragment.firstElementChild) {
let strip = document.createDocumentFragment();
strip.append(...(fragment.firstElementChild.content || fragment.firstElementChild).childNodes);
fragment = strip;
}
let pantry = this.#handlePreservedElements(fragment);
this.#processScripts(fragment);
let parentNode = target.parentNode;
let newContent = [...fragment.childNodes]
if (swapSpec.style === 'innerHTML') {
@@ -1407,6 +1407,8 @@ var htmx = (() => {
if (this.config.logAll) {
console.log(eventName, detail, on)
}
on = this.#normalizeElement(on)
this.#triggerExtensions(on, this.#maybeAdjustMetaCharacter(eventName), detail);
return this.trigger(on, eventName, detail, bubbles)
}
@@ -1482,7 +1484,6 @@ var htmx = (() => {
trigger(on, eventName, detail = {}, bubbles = true) {
on = this.#normalizeElement(on)
this.#triggerExtensions(on, eventName, detail);
let evt = new CustomEvent(eventName, {
detail,
cancelable: true,
@@ -1597,8 +1598,9 @@ var htmx = (() => {
#handleHxOnAttributes(node) {
for (let attr of node.getAttributeNames()) {
if (attr.startsWith(this.#prefix("hx-on:"))) {
let evtName = attr.substring(this.#prefix("hx-on:").length)
var searchString = this.#maybeAdjustMetaCharacter(this.#prefix("hx-on:"));
if (attr.startsWith(searchString)) {
let evtName = attr.substring(searchString.length)
let code = node.getAttribute(attr);
node.addEventListener(evtName, async (evt) => {
try {
@@ -1674,7 +1676,7 @@ var htmx = (() => {
formData.append(submitter.name, submitter.value)
included.add(submitter);
}
let includeSelector = this.#attributeValue(elt, "hx-include");
let {val: includeSelector} = this.#attributeValue(elt, "hx-include") || {};
if (includeSelector) {
let includeNodes = this.#findAllExt(elt, includeSelector);
for (let node of includeNodes) {
@@ -1715,7 +1717,7 @@ var htmx = (() => {
}
#handleHxVals(elt, body) {
let hxValsValue = this.#attributeValue(elt, "hx-vals");
let {val: hxValsValue} = this.#attributeValue(elt, "hx-vals") || {};
if (hxValsValue) {
if (!hxValsValue.includes('{')) {
hxValsValue = `{${hxValsValue}}`
@@ -2065,7 +2067,7 @@ var htmx = (() => {
}
let str = status + ""
for (let pattern of [str, str.slice(0, 2) + 'x', str[0] + 'xx']) {
let swap = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern);
let {val: swap} = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern) || {};
if (swap) {
ctx.swap = swap
return
@@ -2083,7 +2085,7 @@ var htmx = (() => {
});
}
async __processTransitionQueue() {
async #processTransitionQueue() {
if (this.#transitionQueue.length === 0 || this.#processingTransition) {
return;
}
@@ -2133,6 +2135,14 @@ var htmx = (() => {
return cssOrElement
}
}
#maybeAdjustMetaCharacter(string) {
if (this.config.metaCharacter) {
return string.replace(/:/g, this.config.metaCharacter);
} else {
return string;
}
}
}
return new Htmx()

BIN
dist/htmx.esm.js.br vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

164
dist/htmx.js vendored
View File

@@ -72,7 +72,7 @@ var htmx = (() => {
this.#initHtmxConfig();
this.#initRequestIndicatorCss();
this.#actionSelector = `[${this.#prefix("hx-action")}],[${this.#prefix("hx-get")}],[${this.#prefix("hx-post")}],[${this.#prefix("hx-put")}],[${this.#prefix("hx-patch")}],[${this.#prefix("hx-delete")}]`;
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[ starts-with(name(), "${this.#prefix("hx-on:")}")]]`);
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[ starts-with(name(), "${this.#prefix("hx-on")}")]]`);
this.#internalAPI = {
attributeValue: this.#attributeValue.bind(this),
parseTriggerSpecs: this.#parseTriggerSpecs.bind(this),
@@ -110,6 +110,7 @@ var htmx = (() => {
},
morphIgnore: ["data-htmx-powered"],
noSwap: [204],
implicitInheritance: false
}
let metaConfig = document.querySelector('meta[name="htmx:config"]');
if (metaConfig) {
@@ -176,29 +177,41 @@ var htmx = (() => {
#attributeValue(elt, name, defaultVal) {
name = this.#prefix(name);
let appendName = name + ":append";
let inheritName = name + ":inherited";
let inheritAppendName = name + ":inherited:append";
let appendName = name + this.#maybeAdjustMetaCharacter(":append");
let inheritName = name + (this.config.implicitInheritance ? "" : this.#maybeAdjustMetaCharacter(":inherited"));
let inheritAppendName = name + this.#maybeAdjustMetaCharacter(":inherited:append");
if (elt.hasAttribute(name) || elt.hasAttribute(inheritName)) {
return elt.getAttribute(name) || elt.getAttribute(inheritName);
if (elt.hasAttribute(name)) {
return{ val: elt.getAttribute(name), src: elt };
}
if (elt.hasAttribute(inheritName)) {
return{ val: elt.getAttribute(inheritName), src: elt };
}
if (elt.hasAttribute(appendName) || elt.hasAttribute(inheritAppendName)) {
let appendValue = elt.getAttribute(appendName) || elt.getAttribute(inheritAppendName);
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
if (parent) {
let inheritedValue = this.#attributeValue(parent, name);
return inheritedValue ? inheritedValue + "," + appendValue : appendValue;
let inherited = this.#attributeValue(parent, name);
return {
val: inherited.val ? inherited.val + "," + appendValue : appendValue,
src: elt
};
} else {
return {val: appendValue, src: elt};
}
return appendValue;
}
let parent = elt.parentNode?.closest?.(`[${CSS.escape(inheritName)}],[${CSS.escape(inheritAppendName)}]`);
if (parent) {
return this.#attributeValue(parent, name);
let valAndSrc = this.#attributeValue(parent, name);
if (valAndSrc && this.config.implicitInheritance) {
this.#trigger(elt, "htmx:after:implicitInheritance", {elt, parent})
}
return valAndSrc;
}
return defaultVal;
return{ val: defaultVal, src: elt };
}
#tokenize(str) {
@@ -263,13 +276,14 @@ var htmx = (() => {
if (this.#isBoosted(elt)) {
return this.#boostedMethodAndAction(elt, evt)
} else {
let method = this.#attributeValue(elt, "hx-method") || "get";
let action = this.#attributeValue(elt, "hx-action");
let valueAndElt = this.#attributeValue(elt, "hx-method")
let method = valueAndElt.val || "GET"
let {val: action} = this.#attributeValue(elt, "hx-action") || {};
if (!action) {
for (let verb of this.#verbs) {
let verbAttribute = this.#attributeValue(elt, "hx-" + verb);
if (verbAttribute) {
action = verbAttribute;
let {val: verbAction} = this.#attributeValue(elt, "hx-" + verb) || {};
if (verbAction) {
action = verbAction;
method = verb;
break;
}
@@ -319,15 +333,15 @@ var htmx = (() => {
sourceElement,
sourceEvent,
status: "created",
select: this.#attributeValue(sourceElement, "hx-select"),
selectOOB: this.#attributeValue(sourceElement, "hx-select-oob"),
target: this.#attributeValue(sourceElement, "hx-target"),
swap: this.#attributeValue(sourceElement, "hx-swap", this.config.defaultSwap),
push: this.#attributeValue(sourceElement, "hx-push-url"),
replace: this.#attributeValue(sourceElement, "hx-replace-url"),
select: this.#attributeValue(sourceElement, "hx-select")?.val,
selectOOB: this.#attributeValue(sourceElement, "hx-select-oob")?.val,
target: this.#attributeValue(sourceElement, "hx-target")?.val,
swap: this.#attributeValue(sourceElement, "hx-swap", this.config.defaultSwap)?.val,
push: this.#attributeValue(sourceElement, "hx-push-url")?.val,
replace: this.#attributeValue(sourceElement, "hx-replace-url")?.val,
transition: this.config.transitions,
request: {
validate: "true" === this.#attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false"),
validate: "true" === this.#attributeValue(sourceElement, "hx-validate", sourceElement.matches('form') ? "true" : "false")?.val,
action,
method,
headers: this.#determineHeaders(sourceElement)
@@ -335,7 +349,7 @@ var htmx = (() => {
};
// Apply hx-config overrides
let configAttr = this.#attributeValue(sourceElement, "hx-config");
let {val: configAttr} = this.#attributeValue(sourceElement, "hx-config") || {};
if (configAttr) {
let configOverrides = JSON.parse(configAttr);
let requestConfig = ctx.request;
@@ -364,7 +378,7 @@ var htmx = (() => {
if (this.#isBoosted(elt)) {
headers["HX-Boosted"] = "true"
}
let headersAttribute = this.#attributeValue(elt, "hx-headers");
let {val: headersAttribute} = this.#attributeValue(elt, "hx-headers") || {};
if (headersAttribute) {
Object.assign(headers, JSON.parse(headersAttribute));
}
@@ -375,11 +389,7 @@ var htmx = (() => {
if (selector instanceof Element) {
return selector;
} else if (selector === 'this') {
if (elt.hasAttribute(this.#prefix("hx-target"))) {
return elt;
} else {
return elt.closest(`[${this.#prefix("hx-target")}\\:inherited='this']`)
}
return this.#attributeValue(elt, "hx-target").src
} else if (selector != null) {
return this.find(elt, selector);
} else if (this.#isBoosted(elt)) {
@@ -445,7 +455,7 @@ var htmx = (() => {
let params = new URLSearchParams(ctx.request.body);
if (params.size) ctx.request.action += (/\?/.test(ctx.request.action) ? "&" : "?") + params
ctx.request.body = null
} else if (this.#attributeValue(elt, "hx-encoding") !== "multipart/form-data") {
} else if (this.#attributeValue(elt, "hx-encoding")?.val !== "multipart/form-data") {
ctx.request.body = new URLSearchParams(ctx.request.body);
}
@@ -462,14 +472,14 @@ var htmx = (() => {
ctx.status = "issuing"
this.#initTimeout(ctx);
let indicatorsSelector = this.#attributeValue(elt, "hx-indicator");
let {val: indicatorsSelector} = this.#attributeValue(elt, "hx-indicator") || {};
let indicators = this.#showIndicators(elt, indicatorsSelector);
let disableSelector = this.#attributeValue(elt, "hx-disable");
let {val: disableSelector} = this.#attributeValue(elt, "hx-disable") || {};
let disableElements = this.#disableElements(elt, disableSelector);
try {
// Confirm dialog
let confirmVal = this.#attributeValue(elt, 'hx-confirm')
let {val: confirmVal} = this.#attributeValue(elt, 'hx-confirm') || {};
if (confirmVal) {
let js = this.#extractJavascriptContent(confirmVal);
if (js) {
@@ -700,12 +710,12 @@ var htmx = (() => {
}
#determineSyncStrategy(elt) {
let syncValue = this.#attributeValue(elt, "hx-sync");
let {val: syncValue} = this.#attributeValue(elt, "hx-sync") || {};
return syncValue?.split(":")[1] || "queue first";
}
#getRequestQueue(elt) {
let syncValue = this.#attributeValue(elt, "hx-sync");
let {val: syncValue} = this.#attributeValue(elt, "hx-sync") || {};
let syncElt = elt
if (syncValue && syncValue.includes(":")) {
let strings = syncValue.split(":");
@@ -742,7 +752,7 @@ var htmx = (() => {
}
#initializeTriggers(elt, initialHandler = elt._htmx.eventHandler) {
let specString = this.#attributeValue(elt, "hx-trigger");
let {val: specString} = this.#attributeValue(elt, "hx-trigger") || {};
if (!specString) {
specString = elt.matches("form") ? "submit" :
elt.matches("input:not([type=button]),select,textarea") ? "change" :
@@ -892,7 +902,7 @@ var htmx = (() => {
}
#initializeStreamConfig(elt) {
let streamSpec = this.#attributeValue(elt, 'hx-stream');
let {val: streamSpec} = this.#attributeValue(elt, 'hx-stream') || {};
if (!streamSpec) return;
// Start with global defaults
@@ -1002,7 +1012,7 @@ var htmx = (() => {
}
#maybeBoost(elt) {
if (this.#attributeValue(elt, "hx-boost") === "true") {
if (this.#attributeValue(elt, "hx-boost")?.val === "true") {
if (this.#shouldInitialize(elt)) {
elt._htmx = {eventHandler: this.#createHtmxEventHandler(elt), requests: [], boosted: true}
elt.setAttribute('data-htmx-powered', 'true');
@@ -1077,7 +1087,8 @@ var htmx = (() => {
#makeFragment(text) {
let response = text.replace(/<partial(\s+|>)/gi, '<template partial$1').replace(/<\/partial>/gi, '</template>');
// TODO - store any head tag content on the fragment for head extension
let title = '';
response = response.replace(/<title[^>]*>([\s\S]*?)<\/title>/i, (m, t) => (title = t, ''));
let responseWithNoHead = response.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i, '');
let startTag = responseWithNoHead.match(/<([a-z][^\/>\x20\t\r\n\f]*)/i)?.[1]?.toLowerCase();
@@ -1092,10 +1103,11 @@ var htmx = (() => {
doc = this.#parseHTML(`<template>${responseWithNoHead}</template>`);
fragment = doc.querySelector('template').content;
}
this.#processScripts(fragment);
return {
fragment,
title: doc.title
title
};
}
@@ -1114,18 +1126,9 @@ var htmx = (() => {
let swapSpec = this.#parseSwapSpec(oobValue);
if (swapSpec.target) target = swapSpec.target;
let oobElementClone = elt.cloneNode(true);
let fragment;
if (swapSpec.strip === undefined && swapSpec.style !== 'outerHTML') {
swapSpec.strip = true;
}
if (swapSpec.strip) {
fragment = oobElementClone.content || oobElementClone;
} else {
fragment = document.createDocumentFragment();
fragment.appendChild(oobElementClone);
}
elt.remove();
swapSpec.strip ??= !swapSpec.style.startsWith('outer');
let fragment = document.createDocumentFragment();
fragment.append(elt);
if (!target && !oobValue.includes('target:')) return;
tasks.push({
@@ -1255,6 +1258,7 @@ var htmx = (() => {
async swap(ctx) {
let {fragment, title} = this.#makeFragment(ctx.text);
ctx.title = title;
let tasks = [];
// Process OOB and partials
@@ -1263,7 +1267,7 @@ var htmx = (() => {
tasks.push(...oobTasks, ...partialTasks);
// Process main swap
let mainSwap = this.#processMainSwap(ctx, fragment, partialTasks, title);
let mainSwap = this.#processMainSwap(ctx, fragment, partialTasks);
if (mainSwap) {
tasks.push(mainSwap);
}
@@ -1299,7 +1303,7 @@ var htmx = (() => {
}
this.#trigger(document, "htmx:after:swap", {ctx});
if (mainSwap?.title) document.title = mainSwap.title;
if (ctx.title && !mainSwap?.swapSpec?.ignoreTitle) document.title = ctx.title;
await this.timeout(1);
// invoke restore tasks
for (let task of tasks) {
@@ -1312,33 +1316,24 @@ var htmx = (() => {
// if (ctx.hx?.triggerafterswap) this.#handleTriggerHeader(ctx.hx.triggerafterswap, ctx.sourceElement);
}
#processMainSwap(ctx, fragment, partialTasks, title) {
#processMainSwap(ctx, fragment, partialTasks) {
// Create main task if needed
let swapSpec = this.#parseSwapSpec(ctx.swap || this.config.defaultSwap);
// skip creating main swap if extracting partials resulted in empty response except for delete style
if (swapSpec.style === 'delete' || /\S/.test(fragment.innerHTML || '') || !partialTasks.length) {
let resultFragment = document.createDocumentFragment();
if (ctx.select) {
let selected = fragment.querySelector(ctx.select);
if (selected) {
if (swapSpec.strip === false) {
resultFragment.append(selected);
} else {
resultFragment.append(...selected.childNodes);
}
}
} else {
resultFragment.append(...fragment.childNodes);
let selected = fragment.querySelectorAll(ctx.select);
fragment = document.createDocumentFragment();
fragment.append(...selected);
}
let mainSwap = {
type: 'main',
fragment: resultFragment,
fragment,
target: swapSpec.target || ctx.target,
swapSpec,
sourceElement: ctx.sourceElement,
transition: (ctx.transition !== false) && (swapSpec.transition !== false),
title
transition: (ctx.transition !== false) && (swapSpec.transition !== false)
};
return mainSwap;
}
@@ -1350,8 +1345,13 @@ var htmx = (() => {
target = document.querySelector(target);
}
if (!target) return;
if (swapSpec.strip && fragment.firstElementChild) {
let strip = document.createDocumentFragment();
strip.append(...(fragment.firstElementChild.content || fragment.firstElementChild).childNodes);
fragment = strip;
}
let pantry = this.#handlePreservedElements(fragment);
this.#processScripts(fragment);
let parentNode = target.parentNode;
let newContent = [...fragment.childNodes]
if (swapSpec.style === 'innerHTML') {
@@ -1407,6 +1407,8 @@ var htmx = (() => {
if (this.config.logAll) {
console.log(eventName, detail, on)
}
on = this.#normalizeElement(on)
this.#triggerExtensions(on, this.#maybeAdjustMetaCharacter(eventName), detail);
return this.trigger(on, eventName, detail, bubbles)
}
@@ -1482,7 +1484,6 @@ var htmx = (() => {
trigger(on, eventName, detail = {}, bubbles = true) {
on = this.#normalizeElement(on)
this.#triggerExtensions(on, eventName, detail);
let evt = new CustomEvent(eventName, {
detail,
cancelable: true,
@@ -1597,8 +1598,9 @@ var htmx = (() => {
#handleHxOnAttributes(node) {
for (let attr of node.getAttributeNames()) {
if (attr.startsWith(this.#prefix("hx-on:"))) {
let evtName = attr.substring(this.#prefix("hx-on:").length)
var searchString = this.#maybeAdjustMetaCharacter(this.#prefix("hx-on:"));
if (attr.startsWith(searchString)) {
let evtName = attr.substring(searchString.length)
let code = node.getAttribute(attr);
node.addEventListener(evtName, async (evt) => {
try {
@@ -1674,7 +1676,7 @@ var htmx = (() => {
formData.append(submitter.name, submitter.value)
included.add(submitter);
}
let includeSelector = this.#attributeValue(elt, "hx-include");
let {val: includeSelector} = this.#attributeValue(elt, "hx-include") || {};
if (includeSelector) {
let includeNodes = this.#findAllExt(elt, includeSelector);
for (let node of includeNodes) {
@@ -1715,7 +1717,7 @@ var htmx = (() => {
}
#handleHxVals(elt, body) {
let hxValsValue = this.#attributeValue(elt, "hx-vals");
let {val: hxValsValue} = this.#attributeValue(elt, "hx-vals") || {};
if (hxValsValue) {
if (!hxValsValue.includes('{')) {
hxValsValue = `{${hxValsValue}}`
@@ -2065,7 +2067,7 @@ var htmx = (() => {
}
let str = status + ""
for (let pattern of [str, str.slice(0, 2) + 'x', str[0] + 'xx']) {
let swap = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern);
let {val: swap} = this.#attributeValue(ctx.sourceElement, "hx-status:" + pattern) || {};
if (swap) {
ctx.swap = swap
return
@@ -2133,6 +2135,14 @@ var htmx = (() => {
return cssOrElement
}
}
#maybeAdjustMetaCharacter(string) {
if (this.config.metaCharacter) {
return string.replace(/:/g, this.config.metaCharacter);
} else {
return string;
}
}
}
return new Htmx()

BIN
dist/htmx.js.br vendored

Binary file not shown.

2
dist/htmx.min.js vendored

File diff suppressed because one or more lines are too long

BIN
dist/htmx.min.js.br vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -91,7 +91,9 @@
<!-- Test helpers -->
<script src="./lib/helpers.js"></script>
<!-- Unit tests first -->
<!-- ============================================ -->
<!-- Unit Tests -->
<!-- ============================================ -->
<script src="./tests/unit/__attributeValue.js"></script>
<script src="./tests/unit/__collectFormData.js"></script>
<script src="./tests/unit/__disableEnableElements.js"></script>
@@ -129,12 +131,9 @@
<script src="./tests/unit/timeout.js"></script>
<script src="./tests/unit/trigger.js"></script>
<!-- Direct/Fast tests -->
<script src="./tests/direct/hx-get.js"></script>
<script src="./tests/direct/hx-on-xxx.js"></script>
<script src="./tests/direct/hx-optimistic.js"></script>
<!-- ============================================ -->
<!-- Attribute Tests -->
<!-- ============================================ -->
<script src="./tests/attributes/hx-boost.js"></script>
<script src="./tests/attributes/hx-config.js"></script>
<script src="./tests/attributes/hx-get.js"></script>
@@ -150,15 +149,15 @@
<script src="./tests/attributes/hx-trigger.js"></script>
<script src="./tests/attributes/hx-vals.js"></script>
<!-- E2E Tests -->
<script src="./tests/e2e/cancel-behavior.js"></script>
<script src="./tests/e2e/core.js"></script>
<script src="./tests/e2e/oob.js"></script>
<script src="./tests/e2e/sse.js"></script>
<script src="./tests/e2e/strip.js"></script>
<!-- History Tests -->
<script src="./tests/history/basic-history.js"></script>
<!-- ============================================ -->
<!-- End-to-End Tests -->
<!-- ============================================ -->
<script src="./tests/end2end/basic-history.js"></script>
<script src="./tests/end2end/cancel-behavior.js"></script>
<script src="./tests/end2end/core.js"></script>
<script src="./tests/end2end/oob.js"></script>
<script src="./tests/end2end/sse.js"></script>
<script src="./tests/end2end/strip.js"></script>
<script>
// Run tests on load

View File

@@ -1,25 +0,0 @@
describe('direct hx-get attribute test', function() {
beforeEach(() => {
setupTest(this.currentTest)
})
afterEach(() => {
cleanupTest(this.currentTest)
})
it('properly swaps content', async function () {
mockResponse('GET', '/test', 'Clicked!')
let btn = createProcessedHTML('<button hx-get="/test">Click Me!</button> Hey!');
await directlyInvokeHandler(btn)
playground().innerText.should.equal('Clicked! Hey!')
})
it('properly swaps content with outer swap', async function () {
mockResponse('GET', '/test', 'Clicked!')
let btn = createProcessedHTML('<button hx-get="/test" hx-swap="outerHTML">Click Me!</button>');
await directlyInvokeHandler(btn)
playground().innerHTML.should.equal('Clicked!')
})
})

View File

@@ -1,50 +0,0 @@
describe('fast hx-on:xxx response retarting attribute test', function() {
beforeEach(() => {
setupTest(this.currentTest)
})
afterEach(() => {
cleanupTest(this.currentTest)
})
it('retargets based on a full response code', async function () {
mockResponse('GET', '/test', 'Clicked!', {status:400})
let btn = createProcessedHTML('<button hx-status:400="innerHTML target:#d1" hx-get="/test">Click Me!</button><div id="d1"></div>');
await directlyInvokeHandler(btn)
htmx.find("#d1").innerText.should.equal('Clicked!')
})
it('retargets based on a one char partial response code', async function () {
mockResponse('GET', '/test', 'Clicked!', {status:400})
let btn = createProcessedHTML('<button hx-status:40x="innerHTML target:#d1" hx-get="/test">Click Me!</button><div id="d1"></div>');
await directlyInvokeHandler(btn)
htmx.find("#d1").innerText.should.equal('Clicked!')
})
it('retargets based on a two char partial response code', async function () {
mockResponse('GET', '/test', 'Clicked!', {status:400})
let btn = createProcessedHTML('<button hx-status:4xx="innerHTML target:#d1" hx-get="/test">Click Me!</button><div id="d1"></div>');
await directlyInvokeHandler(btn)
htmx.find("#d1").innerText.should.equal('Clicked!')
})
it('more specific wins over less partial response code', async function () {
mockResponse('GET', '/test', 'Clicked!', {status:400})
let btn = createProcessedHTML('<button hx-status:400="innerHTML target:#d2" hx-status:40x="innerHTML target:#d1" hx-get="/test">Click Me!</button><div id="d1"></div><div id="d2"></div>');
await directlyInvokeHandler(btn)
htmx.find("#d1").innerText.should.equal('')
htmx.find("#d2").innerText.should.equal('Clicked!')
})
it('more specific inherited wins over less partial response code', async function () {
mockResponse('GET', '/test', 'Clicked!', {status:400})
let btn = createProcessedHTML('<div hx-status:400:inherited="innerHTML target:#d2"><button id="btn1" hx-status:40x="innerHTML target:#d1" hx-get="/test">Click Me!</button><div id="d1"></div><div id="d2"></div></div>div>');
await directlyInvokeHandler(htmx.find("#btn1"))
htmx.find("#d1").innerText.should.equal('')
htmx.find("#d2").innerText.should.equal('Clicked!')
})
})

View File

@@ -1,56 +0,0 @@
describe('direct hx-optimistic attribute test', function() {
beforeEach(() => {
setupTest(this.currentTest)
})
afterEach(() => {
cleanupTest(this.currentTest)
})
it('shows optimistic content then replaces with response w/innerHTML', async function () {
mockResponse('GET', '/test', 'Server Response')
let btn = createProcessedHTML('<button hx-get="/test" hx-optimistic="#optimistic-source">Initial</button><div id="optimistic-source">Optimistic</div>');
let promise = directlyInvokeHandler(btn);
btn.innerText.should.equal("InitialOptimistic") // text content is hidden
await promise
btn.innerText.should.equal("Server Response")
})
it('shows optimistic content then reverts on request failure w/innerHTML', async function () {
mockFailure('GET', '/test', 'Network error')
let btn = createProcessedHTML('<button hx-get="/test" hx-optimistic="#optimistic-source">Initial</button><div id="optimistic-source">Optimistic</div>');
let promise = directlyInvokeHandler(btn);
btn.innerText.should.equal("InitialOptimistic")
await promise.catch(() => {}) // swallow error
btn.innerText.should.equal("Initial") // reverts to original
})
it('shows optimistic content then replaces with response w/outerHTML', async function () {
mockResponse('GET', '/test', 'Server Response')
let btn = createProcessedHTML('<button hx-get="/test" hx-optimistic="#optimistic-source" hx-swap="outerHTML">Initial</button><div id="optimistic-source">Optimistic</div>');
let promise = directlyInvokeHandler(btn);
playground().innerText.trim().should.include("Optimistic")
await promise
playground().innerText.trim().should.include("Server Response")
})
it('shows optimistic content then reverts on request failure w/outerHTML', async function () {
mockFailure('GET', '/test', 'Network error')
let btn = createProcessedHTML('<button hx-get="/test" hx-optimistic="#optimistic-source" hx-swap="outerHTML">Initial</button><div id="optimistic-source">Optimistic</div>');
let promise = directlyInvokeHandler(btn);
playground().innerText.should.equal("Optimistic\nOptimistic")
await promise.catch(() => {}) // swallow error
playground().innerText.should.equal("Initial\nOptimistic") // reverts to original
})
it('works with new swap terminology: prepend', async function () {
mockResponse('GET', '/test', 'Response')
let btn = createProcessedHTML('<button hx-get="/test" hx-optimistic="#optimistic-source" hx-swap="prepend">Initial</button><div id="optimistic-source">Optimistic</div>');
let promise = directlyInvokeHandler(btn);
btn.innerText.should.equal("OptimisticInitial")
await promise
btn.innerText.should.equal("ResponseInitial")
})
})

View File

@@ -1,31 +0,0 @@
<html lang="en">
<head>
<title>htmx scratch pad</title>
<script src="../../lib/fetch-mock.js"></script>
<script src="../../../src/htmx.js"></script>
<script>
// installFetchMock();
fetchMock.mockResponse('GET', '/demo', new MockResponse('<div id="result">Success!</div>'));
</script>
</head>
<body>
<form>
<input required>
<button hx-post="/demo" hx-validate="true">Submit</button>
</form>
<button hx-get="demo.html">
Get it...
</button>
<button hx-action="js:alert('clicked!')">
Javascript Action
</button>
<div hx-trigger="every 1s" hx-get="demo.html">
Test
</div>
</body>
</html>