Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/ext/hx-browser-indicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
navigation.addEventListener('navigate', (event) => {
if (!event.canIntercept) return;

// save state before intercept navigation.navigate() with {history:'replace'} wipes it
// save state before intercept (navigation.navigate() with {history:'replace'} wipes it)
let savedState = history.state;

let hideBrowserIndicator;
Expand All @@ -40,7 +40,7 @@

cleanupNavigation = () => {
hideBrowserIndicator();
// restore after resolving replaceState during a pending intercept aborts the signal early
// restore after resolving (replaceState during a pending intercept aborts the signal early)
history.replaceState(savedState, '');
};
}, {once: true});
Expand Down
28 changes: 14 additions & 14 deletions src/ext/hx-csp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@
//
// Provides three layers of Content Security Policy integration:
//
// 1. Nonce gating gates htmx attribute processing behind CSP
// 1. Nonce gating: gates htmx attribute processing behind CSP
// nonces to prevent HTML injection attacks. Every htmx element
// must carry an hx-nonce attribute matching the page nonce or
// its htmx attributes are stripped. Fail closed if no page
// nonce is found. Also re-checks nonce presence before internal eval
// to also cover extension eval use like hx-live.
// Nonce source: script[nonce].nonce property on page load.
//
// 2. Trusted Typescreates an 'htmx' TT policy (passthrough
// 2. Trusted Types: creates an 'htmx' TT policy (passthrough;
// trust established by the nonce gate). Add trusted-types htmx
// to your CSP to enforce that only htmx touches DOM sinks.
// Fail closed if policy creation is blocked by CSP.
//
// 3. Safe eval set config.safeEval:true to replace htmx's
// 3. Safe eval: set config.safeEval:true to replace htmx's
// Function/AsyncFunction with nonce-based script injection,
// enabling hx-on:/hx-vals js:/hx-confirm js: without
// unsafe-eval in your CSP.
Expand Down Expand Up @@ -64,7 +64,7 @@
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Rewrites responseNonce replacement in raw HTML before DOM parsing,
// Rewrites responseNonce -> replacement in raw HTML before DOM parsing,
// covering hx-nonce and script nonce attributes in one pass.
// Pass replacement='' to strip nonce attributes entirely (stolen-nonce scrub).
function rewriteNoncesInText(text, responseNonce, replacement = pageNonce) {
Expand All @@ -88,7 +88,7 @@
let tag = elt.tagName?.toLowerCase();
let id = elt.id ? `#${elt.id}` : '';
let reason = eltNonce == null ? 'missing-nonce' : 'nonce-mismatch';
console.error(`htmx: [hx-csp] blocked <${tag}${id}> ${eltNonce == null ? 'no hx-nonce attribute' : 'nonce mismatch (possible injection)'}`, { elt, reason });
console.error(`htmx: [hx-csp] blocked <${tag}${id}>: ${eltNonce == null ? 'no hx-nonce attribute' : 'nonce mismatch (possible injection)'}`, { elt, reason });
htmx.trigger(elt, 'htmx:security:strip', { reason, stripped });
return true;
}
Expand All @@ -103,18 +103,18 @@
pageNonce = document.querySelector('script[nonce]')?.nonce || null;

if (!pageNonce) {
console.error('htmx: [hx-csp] no page nonce found blocking all htmx. Add a nonce to your script tags.');
console.error('htmx: [hx-csp] no page nonce found, blocking all htmx. Add a nonce to your script tags.');
return;
}

// Passthrough TT policy trust established by nonce gate.
// Passthrough TT policy (trust established by nonce gate).
// Fail closed if 'htmx' is not in the trusted-types CSP whitelist.
try {
ttPolicy = typeof trustedTypes !== 'undefined'
? trustedTypes.createPolicy('htmx', { createHTML: s => s, createScript: s => s })
: { createHTML: s => s, createScript: s => s };
} catch (e) {
console.error("htmx: [hx-csp] TrustedTypes policy 'htmx' blocked add 'htmx' to trusted-types CSP directive. Blocking all htmx.");
console.error("htmx: [hx-csp] TrustedTypes policy 'htmx' blocked, add 'htmx' to trusted-types CSP directive. Blocking all htmx.");
pageNonce = null;
return;
}
Expand All @@ -123,7 +123,7 @@
let NativeFunction = Function;
let NativeAsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
// Cache compiled script-injected functions by "keys|body" so each unique
// expression is only injected once critical for hx-live which re-evaluates
// expression is only injected once (critical for hx-live which re-evaluates
// the same expressions on every DOM/input tick.
let safeEvalCache = new Map();

Expand All @@ -135,7 +135,7 @@
if (getNonce(thisArg) !== pageNonce) {
let tag = thisArg?.tagName?.toLowerCase();
let id = thisArg?.id ? `#${thisArg.id}` : '';
console.error(`htmx: [hx-csp] blocked eval on <${tag}${id}> nonce mismatch`, { elt: thisArg });
console.error(`htmx: [hx-csp] blocked eval on <${tag}${id}>: nonce mismatch`, { elt: thisArg });
htmx.trigger(thisArg, 'htmx:security:violation', { reason: 'nonce-mismatch-at-eval' });
return;
}
Expand Down Expand Up @@ -175,13 +175,13 @@
if (!pageNonce) return false;
let ctx = detail.ctx;

// Always scrub stolen pageNonce from any response — the server cannot know the
// Always scrub stolen pageNonce from any response. The server cannot know the
// page nonce, so its presence indicates a stolen-nonce injection attempt.
ctx.text = rewriteNoncesInText(ctx.text, pageNonce, '');

// Only promote response nonce for verified same-origin responses
let responseURL = ctx?.response?.raw?.url;
if (!responseURL) return; // can't verify origin scrub only, no promotion
if (!responseURL) return; // can't verify origin; scrub only, no promotion
try { if (new URL(responseURL).origin !== location.origin) return; }
catch (_) { return; }

Expand All @@ -193,7 +193,7 @@
},

// Blocks boosted form submissions where an unnonced submitter overrides formaction.
// Also blocks js:/javascript: action URLs entity encoding doesn't neutralise these
// Also blocks js:/javascript: action URLs (entity encoding doesn't neutralise these
// so they may survive template rendering and execute unexpectedly.
htmx_config_request: (elt, detail) => {
if (!pageNonce) return false;
Expand All @@ -208,7 +208,7 @@
if (!elt._htmx?.boosted || !submitter?.getAttribute('formaction')) return;
if (getNonce(submitter) !== pageNonce) {
let id = submitter?.id ? `#${submitter.id}` : '';
console.error(`htmx: [hx-csp] blocked boosted form unnonced submitter${id} overrode formaction`);
console.error(`htmx: [hx-csp] blocked boosted form: unnonced submitter${id} overrode formaction`);
htmx.trigger(elt, 'htmx:security:violation', { reason: 'unnonced-submitter', submitter, ctx: detail.ctx });
detail.cancelled = true;
return false;
Expand Down
10 changes: 5 additions & 5 deletions src/ext/hx-head.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
for (const attr of newNode.attributes) newElt.setAttribute(attr.name, attr.value)
newElt.textContent = newNode.textContent

// stylesheet await CSSOM or content will flash unstyled
// stylesheet: await CSSOM or content will flash unstyled
if (newNode.tagName === "LINK" && newNode.rel === "stylesheet") {
return new Promise(resolve => {
newElt.onload = resolve
Expand All @@ -23,7 +23,7 @@
})
}

// blocking external script (no async/defer) must init before swap
// blocking external script (no async/defer): must init before swap
if (newNode.tagName === "SCRIPT" && newNode.src && !newNode.async && !newNode.defer) {
return new Promise((resolve, reject) => {
newElt.onload = resolve
Expand All @@ -32,7 +32,7 @@
})
}

// meta, title, base, preload/prefetch/icon links, async scripts fire-and-forget
// meta, title, base, preload/prefetch/icon links, async scripts: fire-and-forget
document.head.appendChild(newElt)
if (newNode._preloadHint) newElt.addEventListener("load", () => newNode._preloadHint.remove(), {once: true})
return null
Expand Down Expand Up @@ -111,7 +111,7 @@
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values())

// defer scripts need the swapped DOM to exist split them out
// defer scripts need the swapped DOM to exist, so split them out
for (const newNode of nodesToAppend) {
if (newNode.tagName === "SCRIPT" && newNode.defer) {
deferred.push(newNode)
Expand Down Expand Up @@ -176,7 +176,7 @@
if (detail.head) {
// mergeHead awaits stylesheets/blocking scripts, returns deferred scripts.
// Set detail.ready so history-cache awaits before swapping body.
// Stash deferred scripts on detail history-cache copies them onto the swap ctx
// Stash deferred scripts on detail so history-cache copies them onto the swap ctx
// so htmx_after_swap picks them up.
detail.ready = mergeHead(detail.head, 'merge').then(deferred => {
detail._deferredHeadScripts = deferred;
Expand Down
6 changes: 3 additions & 3 deletions src/ext/hx-live.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,11 +323,11 @@
*
* @example
* toggle('.active') // toggle class
* toggle('aria-expanded') // flip "true" "false"
* toggle('aria-expanded') // flip "true" <-> "false"
* toggle('hidden') // toggle attribute presence
* toggle('data-view', 'grid|list|table') // cycle attribute through values
* toggle('.size', 'sm|md|lg') // cycle classes (one at a time)
* toggle('data-open', 'on|') // 'on' absent slot
* toggle('data-open', 'on|') // 'on' <-> absent slot
*/
function applyToggle(name, values, element) {
let isClass = name.startsWith('.');
Expand Down Expand Up @@ -497,7 +497,7 @@
if (extra === undefined) {
if (window.Alpine) {
extra = '';
console.warn('hx-live: Alpine.js detected ":" short-form bindings disabled. Set htmx.config.live.bindPrefix to configure.');
console.warn('hx-live: Alpine.js detected; ":" short-form bindings disabled. Set htmx.config.live.bindPrefix to configure.');
} else {
extra = ':';
}
Expand Down
6 changes: 3 additions & 3 deletions src/ext/hx-multipart.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ class MultipartParser {
onPull = null

constructor(boundary) {
// RFC 2046 §5.1.1 limits the boundary to 1-70 ASCII characters from a
// RFC 2046 S5.1.1 limits the boundary to 1-70 ASCII characters from a
// small subset. Real-world implementations stick to printable ASCII; we
// enforce that broader range so non-ASCII boundaries fail loudly instead
// of silently misaligning the parser's char-length arithmetic.
Expand Down Expand Up @@ -587,7 +587,7 @@ class MultipartParser {
* @param {((part: BodyPart) => void) | null} [onPart]
*/
write(chunk, onPart = null) {
// Discard epilogue bytes after the closing boundary (RFC 2046 §5.1.1).
// Discard epilogue bytes after the closing boundary (RFC 2046 S5.1.1).
if (this.#state === State.DONE) return

let index = 0
Expand Down Expand Up @@ -723,7 +723,7 @@ class MultipartParser {
this.#buffer = chunk
break
}
// Discard preamble bytes before the opening boundary (RFC 2046 §5.1.1).
// Discard preamble bytes before the opening boundary (RFC 2046 S5.1.1).
const openingIndex = this.#findOpeningBoundary(chunk)
if (openingIndex === -1) {
const tailStart = chunkLength - (this.#openingBoundaryLength - 1)
Expand Down
6 changes: 3 additions & 3 deletions src/ext/hx-sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@

try {
while (element.isConnected) {
// Reconnection (not on first iteration we already have the response)
// Reconnection (not on first iteration, we already have the response)
if (connection.attempt > 0) {
// Wait while paused (tab backgrounded with pauseOnBackground)
if (paused) {
Expand Down Expand Up @@ -268,7 +268,7 @@
delete detail.message.cancelled;
api.triggerHtmxEvent(element, 'htmx:sse:after:message', detail);

// hx-sse:close="eventname" close connection on matching event
// hx-sse:close="eventname": close connection on matching event
let closeEvent = api.attributeValue(element, 'hx-sse:close');
if (closeEvent && detail.message.event === closeEvent) {
cleanup(element, 'message');
Expand Down Expand Up @@ -370,7 +370,7 @@
let contentType = ctx.response.raw.headers.get('Content-Type');
if (!contentType?.includes('text/event-stream')) return;

// Take over core will return without calling response.text()
// Take over; core will return without calling response.text()
handleSSEResponse(ctx).catch(e => {
api.triggerHtmxEvent(element, 'htmx:sse:error', {error: e, url: ctx.request.action});
cleanup(element);
Expand Down
8 changes: 4 additions & 4 deletions src/ext/hx-ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@
if (elt) {
api.triggerHtmxEvent(elt, 'htmx:after:ws:connection', {connection});
} else {
// Element was removed while connecting orphaned socket
// Element was removed while connecting (orphaned socket)
cleanupOrphanedConnection(url, connection);
return;
}
Expand All @@ -216,7 +216,7 @@
if (config.reconnect && findConnectedElement(url)) {
scheduleReconnect(url, connection);
} else {
// No element or reconnect disabled full cleanup
// No element or reconnect disabled: full cleanup
cleanupOrphanedConnection(url, connection);
}
}, opts);
Expand Down Expand Up @@ -267,7 +267,7 @@
return;
}
} else {
// Element gone no point scheduling reconnect
// Element gone, no point scheduling reconnect
cleanupOrphanedConnection(url, connection);
return;
}
Expand Down Expand Up @@ -433,7 +433,7 @@
}

if (!connectionElement) {
// No element in DOM for this connection orphan cleanup
// No element in DOM for this connection (orphan cleanup)
cleanupOrphanedConnection(connection.url, connection);
return;
}
Expand Down
10 changes: 5 additions & 5 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,14 +655,14 @@ var htmx = (() => {

requestQueue.finish()
if (requestQueue.more()) {
// intentionally not awaited __issueRequest has its own try/catch
// intentionally not awaited; __issueRequest has its own try/catch
this.__issueRequest(requestQueue.next())
}
}
}

// Extract HX-* response headers into ctx.hx
// Maps: HX-Trigger ctx.hx.trigger, HX-Push-Url ctx.hx.pushurl, etc.
// Maps: HX-Trigger -> ctx.hx.trigger, HX-Push-Url -> ctx.hx.pushurl, etc.
__extractHxHeaders(ctx) {
ctx.hx = {}
for (let [k, v] of ctx.response.raw.headers) {
Expand Down Expand Up @@ -1353,7 +1353,7 @@ var htmx = (() => {
}
let swapStyle = swapSpec.style;
if (swapStyle === 'none') return;
// full-page response: fragment has a <body> wrapper upgrade outerHTML to outerSync, strip for everything else
// full-page response: fragment has a <body> wrapper, so upgrade outerHTML to outerSync, strip for everything else
if (fragment.firstElementChild?.tagName === 'BODY') {
if (swapStyle === 'outerHTML') swapStyle = 'outerSync';
else if (!swapStyle.startsWith('outer')) swapSpec.strip = true;
Expand Down Expand Up @@ -1839,7 +1839,7 @@ var htmx = (() => {
} else if (['INPUT', 'SELECT', 'TEXTAREA', 'FIELDSET'].includes(tag) || !isGet) {
inputs = this.__queryEltAndDescendants(elt, '[name]:not(button)');
}
// GET on non-form-control containers (div, etc.) sends nothing use hx-include for explicit inclusion
// GET on non-form-control containers (div, etc.) sends nothing; use hx-include for explicit inclusion

for (let input of inputs) {
let name = input.name || input.getAttribute?.('name');
Expand Down Expand Up @@ -2109,7 +2109,7 @@ var htmx = (() => {
}

__findBestMatch(ctx, node, startPoint, endPoint) {
// text nodes match positionally patch in place via __morphNode, 3 = TEXT_NODE
// text nodes match positionally (patch in place via __morphNode), 3 = TEXT_NODE
if (node.nodeType === 3) return startPoint?.nodeType === 3 ? startPoint : null;
if (!(node instanceof Element)) return null;
let softMatch = null, displaceMatchCount = 0, scanLimit = this.config.morphScanLimit;
Expand Down
20 changes: 20 additions & 0 deletions src/scripts/content/check_ascii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""Check that source files contain only ASCII characters."""

from pathlib import Path

SRC = Path(__file__).parents[2]
ERRORS = []

for f in list(SRC.glob("*.js")) + list(SRC.glob("ext/*.js")):
for i, line in enumerate(f.read_bytes().splitlines(), 1):
if any(b > 127 for b in line):
ERRORS.append(f"{f.name}:{i}: {line!r}")

if ERRORS:
print("Non-ASCII characters found:")
for e in ERRORS:
print(f" {e}")
raise SystemExit(1)

print("OK: All source files are ASCII-clean")
Loading