@lokascript/htmx-adapter 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -86,11 +86,13 @@ declare function resetRegistry(): void;
86
86
  * allows):
87
87
  *
88
88
  * - **The authored attribute is never removed or rewritten** — devtools
89
- * keeps showing what the author wrote. The one exception is an
90
- * author-written canonical `hx-trigger` whose *value* uses localized
91
- * event names (`hx-trigger="clic"`): there is no separate canonical
92
- * target to write to, so the value is translated in place (idempotent —
93
- * the maps are localized → canonical, so a second pass is a no-op).
89
+ * keeps showing what the author wrote. Two documented exceptions:
90
+ * an author-written canonical `hx-trigger` whose *value* uses localized
91
+ * event names (`hx-trigger="clic"`) has no separate canonical target,
92
+ * so the value is translated in place (idempotent — the maps are
93
+ * localized → canonical, so a second pass is a no-op); and in executor
94
+ * mode a canonical-named `hx-on:*` attribute may be removed by the
95
+ * claim when no htmx hook exists to cancel htmx's binding (hx-on.ts).
94
96
  * - **An existing canonical attribute always wins.** If the element
95
97
  * already has `hx-get`, a localized `hx-obtener` never overwrites it.
96
98
  * - **No vocab, no work.** With no languages registered every function
@@ -101,17 +103,40 @@ declare function resetRegistry(): void;
101
103
  * convention: Spanish writes `hx-obtener`, not `xx-obtener`), so the
102
104
  * prefix doubles as the discovery anchor.
103
105
  */
106
+ /**
107
+ * Byte-mirror of htmx v4's `HCON.split` top-level-comma regex (vendored
108
+ * 4.0.0, `HCON.split`; pinned against the vendored file by
109
+ * test/vendor-mirror.test.ts). Spec boundaries must match core's grammar:
110
+ * commas inside `[filters]`, `(calls)`, and quoted strings — e.g.
111
+ * `from:".a, .b"` — are NOT separators.
112
+ *
113
+ * On htmx 4.0.0 the extension's `init(internalAPI)` swaps in htmx's own
114
+ * `HCON.split` (see setTriggerSpecSplitter), so the mirror is the
115
+ * fallback for v2 and for pages where the extension is not registered.
116
+ */
117
+ declare const TOP_LEVEL_COMMA_RE: RegExp;
118
+ type TriggerSpecSplitter = (value: string) => string[];
119
+ /**
120
+ * Use htmx's own top-level splitter (`internalAPI.HCON.split` on 4.0.0)
121
+ * instead of the mirror. Pass `null` to restore the mirror.
122
+ */
123
+ declare function setTriggerSpecSplitter(fn: TriggerSpecSplitter | null): void;
104
124
  /**
105
125
  * Translate localized event names inside an `hx-trigger` value.
106
126
  *
107
127
  * hx-trigger grammar: comma-separated specs, each `eventName[filter]
108
128
  * modifier…`. Only the leading event token of each spec is translated
109
- * (preserving an attached `[...]` filter); modifiers like `delay:500ms`,
110
- * `from:body`, `once` are language-invariant and left alone. Unknown
111
- * tokens pass through untouched, which also makes translation idempotent
112
- * (the maps are localized → canonical only).
129
+ * (an attached `[...]` filter, all modifiers like `delay:500ms` /
130
+ * `from:body` / `once`, and the authored spacing stay byte-identical —
131
+ * the split removes exactly the commas the join restores, so an
132
+ * all-canonical value comes back verbatim). Unknown tokens pass
133
+ * through untouched, which also makes translation idempotent (the maps
134
+ * are localized → canonical only). Only the map's OWN keys translate:
135
+ * `constructor` is not an event.
113
136
  */
114
137
  declare function translateTriggerValue(value: string, events: Record<string, string>): string;
138
+ /** True when `name` on `elt` was written by canonicalization, not authored. */
139
+ declare function wasCreatedByAdapter(elt: Element, name: string): boolean;
115
140
  /**
116
141
  * Canonicalize one element's localized htmx attributes in place.
117
142
  * Returns true if any attribute was added or updated.
@@ -131,18 +156,25 @@ declare function canonicalizeTree(root: Element | Document | DocumentFragment |
131
156
  * Primary target is **htmx v4**, whose extensions register via
132
157
  * `htmx.registerExtension(name, ext)` and hook lifecycle events through
133
158
  * underscore-named methods (event name with `:` → `_`), each receiving
134
- * `(elt, detail)`. Verified against htmx 4.0.0-beta5 (see
159
+ * `(elt, detail)`. Verified against htmx 4.0.0 (see
135
160
  * test/browser/vendor/README.md): `process(root)` fires
136
161
  * `htmx:before:process` on the processed root — `document.body`
137
162
  * initially, each swapped-in subtree afterwards — BEFORE any element
138
163
  * init or hx-on binding, so canonicalizing in `htmx_before_process`
139
164
  * covers everything htmx will read. `htmx_before_process_node` is kept
140
165
  * as a defensive alias for other v4 prereleases that used per-node
141
- * naming; an unmatched key is inert.
166
+ * naming; an unmatched key is inert. The cancelable
167
+ * `htmx_before_on_init` hook (fires per hx-on-carrying node, after
168
+ * before:process; documented as cancelable in htmx 4.0.0's `htmx.d.ts`)
169
+ * lets executor mode keep claimed `hx-on:*` attributes in the DOM
170
+ * instead of removing them — see createExtension.
142
171
  *
143
172
  * A v2 fallback (`htmx.defineExtension` + `onEvent('htmx:beforeProcessNode')`)
144
173
  * is included because the localized attribute names are version-agnostic
145
- * data — but v2 support is best-effort, not a tested target.
174
+ * data — but v2 support is best-effort, not a tested target. Measured on
175
+ * 2.0.10: `processNode` binds `hx-on` (`processHxOnWildcard`) BEFORE it
176
+ * fires `htmx:beforeProcessNode` per node, so v2 has no pre-bind seam and
177
+ * executor-mode claims keep removing canonical `hx-on:*` attrs there.
146
178
  *
147
179
  * The extension hook alone is not enough for the *initial* page: script
148
180
  * order decides whether our sweep beats htmx's own DOMContentLoaded scan.
@@ -154,25 +186,69 @@ declare function canonicalizeTree(root: Element | Document | DocumentFragment |
154
186
  declare const EXTENSION_NAME = "lokascript-i18n";
155
187
  /** Minimal shape of the htmx global we interact with. */
156
188
  interface HtmxLike {
157
- /** htmx v4 registration entry point. */
158
- registerExtension?(name: string, extension: object): void;
189
+ /**
190
+ * htmx v4 registration entry point. Returns `false` when the name is
191
+ * rejected — `htmx.config.extensions` is a non-empty allowlist that
192
+ * omits it, or it was already registered. (`htmx.d.ts` types the
193
+ * return as `void`; the runtime, verified on 4.0.0, returns `false`.)
194
+ */
195
+ registerExtension?(name: string, extension: object): boolean | void;
159
196
  /** htmx v1/v2 registration entry point. */
160
197
  defineExtension?(name: string, extension: object): void;
198
+ /** htmx v4 config — the parts that decide which hx-on spellings it binds. */
199
+ config?: {
200
+ /** Alternate attribute prefix (`data-hx-` by default; `''` disables it). */
201
+ prefix?: string;
202
+ /** Separator between `hx-on` and the event name (`:` by default). */
203
+ metaCharacter?: string;
204
+ };
161
205
  }
162
206
  /**
163
207
  * Build the extension object. v4 hooks and the v2 `onEvent` callback are
164
- * both present — each API only reads the members it knows about.
208
+ * both present — each API only reads the members it knows about. Pass
209
+ * the htmx instance so the v4 hook can read `config.prefix` /
210
+ * `config.metaCharacter` (defaults are assumed otherwise).
211
+ *
212
+ * `init(internalAPI)` (v4 only) takes exactly one thing from the API:
213
+ * `HCON.split`, htmx's own top-level trigger-spec splitter, which
214
+ * replaces the regex mirror in canonicalize.ts for the lifetime of the
215
+ * page. The rest of the 4.0.0 surface — `attributeValue`,
216
+ * `parseTriggerSpecs`, `getAttributeObject`, `insertContent`, `morph`,
217
+ * `initSecurity`, `onTrigger`, `htmxProp`, `triggerHtmxEvent`,
218
+ * `executeJavaScript` — was evaluated and not used: `parseTriggerSpecs`
219
+ * is parse-only (no serializer, so translating through it would mean a
220
+ * hand-written spec serializer); `attributeValue`'s inheritance-aware
221
+ * reads buy nothing for a sweep that visits every element anyway; and
222
+ * `htmxProp(node).onInitialized = true` WOULD keep htmx's binder off a
223
+ * node, but couples to a private flag where `htmx:before:on:init` is a
224
+ * typed, documented, cancelable event — the hook below uses that.
165
225
  */
166
- declare function createExtension(): object;
226
+ declare function createExtension(htmx?: HtmxLike | null): object;
167
227
  /**
168
228
  * Register the extension with an htmx global. Returns which API accepted
169
- * it (`'v4'` / `'v2'`) or `null` if the object exposes neither.
229
+ * it (`'v4'` / `'v2'`) or `null` if the object exposes neither, or if
230
+ * v4 rejected the registration.
231
+ *
232
+ * Only an ACCEPTED v4 registration turns claim-time neutralization off:
233
+ * that is the one runtime whose per-node `htmx:before:on:init` hook can
234
+ * keep htmx off a preserved `hx-on:*` attribute. Everything else keeps
235
+ * the hook-independent removal guard.
170
236
  */
171
237
  declare function registerWith(htmx: HtmxLike | undefined | null): 'v4' | 'v2' | null;
172
238
  /**
173
- * Sweep the whole document now (if parsed) or on DOMContentLoaded, and
174
- * re-sweep whenever a vocab module registers after the initial sweep
175
- * (e.g. a vocab <script> below htmx, or dynamic registration).
239
+ * Sweep the whole document once the DOM is parsed, and re-sweep whenever
240
+ * a vocab module registers or the body executor changes after that
241
+ * (e.g. a vocab <script> below htmx, a lazily loaded _hyperscript, or
242
+ * dynamic registration).
243
+ *
244
+ * "Parsed" means DOMContentLoaded has FIRED (or `readyState` is already
245
+ * `'complete'`) — not merely `readyState !== 'loading'`. During
246
+ * DOMContentLoaded dispatch `readyState` is already `'interactive'`, and
247
+ * a `defer`/module adapter runs at `'interactive'` before htmx exists;
248
+ * sweeping then would claim in the default remove mode before
249
+ * `registerWith` had a chance to turn neutralization off. A `load`
250
+ * listener covers a script injected after DOMContentLoaded but before
251
+ * `'complete'` (DOMContentLoaded will not fire again for it).
176
252
  *
177
253
  * Returns a cleanup function (mainly for tests).
178
254
  */
@@ -253,11 +329,21 @@ declare function installResolverMode(htmx: ResolverConfigurableHtmx): () => void
253
329
  * - Localized-named attrs (`hx-en:clic`) stay verbatim in the DOM — htmx
254
330
  * never recognized them anyway — and NO canonical `hx-on:*` sibling is
255
331
  * created.
256
- * - Canonical-named attrs (`hx-on:click`) are REMOVED after claiming.
257
- * This is the one place the adapter deletes an authored attribute: if
258
- * it stayed, htmx would eval the hyperscript body as JS — a console
259
- * error plus a double-execution attempt on every fire. Documented as
260
- * the executor-mode exception in the README.
332
+ * - Canonical-named attrs (`hx-on:click`) must be kept away from htmx's
333
+ * own binder: if htmx bound them, it would eval the hyperscript body
334
+ * as JS — a console error plus a double-execution attempt on every
335
+ * fire. A claim RECORDS the attribute (name + body) on the element;
336
+ * what keeps htmx off it is decided by whoever owns the runtime:
337
+ * - htmx v4, extension accepted: the extension's cancelable
338
+ * `htmx:before:on:init` hook consults the record per node and
339
+ * cancels htmx's binding — the authored attribute stays in the DOM
340
+ * (see extension.ts). `registerWith` turns claim-time removal OFF
341
+ * only after the registration actually succeeded.
342
+ * - everywhere else (htmx v2, a rejected v4 registration, no htmx):
343
+ * `neutralizeOnClaim` is ON (the default) and the canonical-named
344
+ * attribute is removed at claim time — the only guard that does not
345
+ * depend on a hook being installed, documented as the executor-mode
346
+ * exception in the README.
261
347
  *
262
348
  * With no executor set (the default), none of this runs and bodies keep
263
349
  * upstream JS semantics — the behavior-preservation invariant.
@@ -266,10 +352,13 @@ declare function installResolverMode(htmx: ResolverConfigurableHtmx): () => void
266
352
  * loads after the initial sweep still applies. Both auto-detection
267
353
  * (`autoDetectBodyHooks`) and manual configuration are supported; the
268
354
  * executor is also re-read at fire time so replacing it takes effect on
269
- * live listeners.
355
+ * live listeners. The body is re-read on every re-claim, so
356
+ * `htmx.process(elt, true)` after editing the attribute runs the new body.
270
357
  */
271
358
  type BodyExecutor = (code: string, elt: Element, evt: Event) => unknown;
272
359
  type BodyTranslator = (body: string, lang: string) => string;
360
+ declare function setNeutralizeOnClaim(enabled: boolean): void;
361
+ declare function neutralizesOnClaim(): boolean;
273
362
  /** Configure the body executor. Setting/clearing it notifies subscribers. */
274
363
  declare function setBodyExecutor(fn: BodyExecutor | null): void;
275
364
  /** Configure the body translator (localized body → English hyperscript). */
@@ -278,16 +367,37 @@ declare function hasBodyExecutor(): boolean;
278
367
  declare function hasBodyTranslator(): boolean;
279
368
  /** Subscribe to executor changes. Returns an unsubscribe fn. */
280
369
  declare function onBodyHooksChanged(listener: () => void): () => void;
370
+ /** True when the adapter has claimed at least one hx-on attribute on `elt`. */
371
+ declare function isClaimed(elt: Element): boolean;
372
+ /** Names of the attributes claimed on `elt` (canonical and localized). */
373
+ declare function claimedAttrNames(elt: Element): Set<string>;
374
+ /**
375
+ * Remove the claimed CANONICAL-named (`hx-on:*`) attributes from `elt`.
376
+ * The per-node fallback for runtimes that will bind the node anyway
377
+ * (v4 mixed nodes). Localized names are invisible to htmx and stay.
378
+ * Returns how many were removed.
379
+ */
380
+ declare function removeClaimedCanonicalAttrs(elt: Element): number;
381
+ interface ClaimOptions {
382
+ /**
383
+ * The attribute was written by the adapter's own canonicalization (a
384
+ * no-executor sweep's `hx-on:*` copy of a localized attr), not by the
385
+ * author. Such a duplicate is always removed on claim.
386
+ */
387
+ adapterCreated?: boolean;
388
+ }
281
389
  /**
282
- * Claim one hx-on-family attribute on an element: install the executor
283
- * listener and neutralize htmx's view of it. Returns true if the claim
284
- * happened now (false when no executor, already claimed, or malformed).
390
+ * Claim one hx-on-family attribute on an element: record it, install the
391
+ * executor listener, and — when claim-time neutralization is on — remove
392
+ * a canonical-named attribute so htmx never JS-evals it. Returns true if
393
+ * the element changed or a listener was installed now (false when no
394
+ * executor, already claimed for that event, or malformed).
285
395
  *
286
396
  * `lang` is the element's language at claim time — used for lazy body
287
397
  * translation. `events` is the language's event-name map (for the
288
398
  * listener's event name; the shorthand/unknown cases pass through).
289
399
  */
290
- declare function claimHxOnAttribute(elt: Element, attrName: string, lang: string, events: Record<string, string>): boolean;
400
+ declare function claimHxOnAttribute(elt: Element, attrName: string, lang: string, events: Record<string, string>, options?: ClaimOptions): boolean;
291
401
  /**
292
402
  * Auto-detect body hooks from page globals — called by the browser entry
293
403
  * at load and again at DOMContentLoaded. Never overwrites hooks that
@@ -302,4 +412,4 @@ declare function autoDetectBodyHooks(win: object): void;
302
412
  /** Reset all body-hook state. Mainly for tests. */
303
413
  declare function resetBodyHooks(): void;
304
414
 
305
- export { type BodyExecutor, type BodyTranslator, EXTENSION_NAME, type HtmxLike, type HtmxVocab, type VocabPayload, additionalAttributeSelectors, attributeResolver, autoDetectBodyHooks, canonicalizeElement, canonicalizeTree, claimHxOnAttribute, createExtension, hasAnyVocab, hasBodyExecutor, hasBodyTranslator, installAutoSweep, installResolverMode, isLangRegistered, isResolverMode, langOf, normLang, onBodyHooksChanged, onVocabUpdate, register, registerWith, resetBodyHooks, resetRegistry, setBodyExecutor, setBodyTranslator, setResolverMode, translateTriggerValue, vocabFor };
415
+ export { type BodyExecutor, type BodyTranslator, type ClaimOptions, EXTENSION_NAME, type HtmxLike, type HtmxVocab, TOP_LEVEL_COMMA_RE, type TriggerSpecSplitter, type VocabPayload, additionalAttributeSelectors, attributeResolver, autoDetectBodyHooks, canonicalizeElement, canonicalizeTree, claimHxOnAttribute, claimedAttrNames, createExtension, hasAnyVocab, hasBodyExecutor, hasBodyTranslator, installAutoSweep, installResolverMode, isClaimed, isLangRegistered, isResolverMode, langOf, neutralizesOnClaim, normLang, onBodyHooksChanged, onVocabUpdate, register, registerWith, removeClaimedCanonicalAttrs, resetBodyHooks, resetRegistry, setBodyExecutor, setBodyTranslator, setNeutralizeOnClaim, setResolverMode, setTriggerSpecSplitter, translateTriggerValue, vocabFor, wasCreatedByAdapter };
package/dist/index.js CHANGED
@@ -57,10 +57,18 @@ function resetRegistry() {
57
57
  }
58
58
 
59
59
  // src/hx-on.ts
60
+ var hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
60
61
  var executor = null;
61
62
  var translator = null;
62
63
  var hookChangeListeners = /* @__PURE__ */ new Set();
63
64
  var claimed = /* @__PURE__ */ new WeakMap();
65
+ var neutralizeOnClaim = true;
66
+ function setNeutralizeOnClaim(enabled) {
67
+ neutralizeOnClaim = enabled;
68
+ }
69
+ function neutralizesOnClaim() {
70
+ return neutralizeOnClaim;
71
+ }
64
72
  function setBodyExecutor(fn) {
65
73
  executor = fn;
66
74
  for (const listener of hookChangeListeners) listener();
@@ -80,44 +88,76 @@ function onBodyHooksChanged(listener) {
80
88
  hookChangeListeners.delete(listener);
81
89
  };
82
90
  }
91
+ function isClaimed(elt) {
92
+ return (claimed.get(elt)?.size ?? 0) > 0;
93
+ }
94
+ function claimedAttrNames(elt) {
95
+ const names = /* @__PURE__ */ new Set();
96
+ const claims = claimed.get(elt);
97
+ if (claims) for (const c of claims.values()) names.add(c.attrName);
98
+ return names;
99
+ }
100
+ function removeClaimedCanonicalAttrs(elt) {
101
+ let n = 0;
102
+ for (const name of claimedAttrNames(elt)) {
103
+ if (name.startsWith("hx-on:") && elt.hasAttribute(name)) {
104
+ elt.removeAttribute(name);
105
+ n++;
106
+ }
107
+ }
108
+ return n;
109
+ }
83
110
  function eventNameForSuffix(rawSuffix, events) {
84
111
  if (rawSuffix.startsWith(":")) return `htmx${rawSuffix}`;
85
- return events[rawSuffix] ?? rawSuffix;
112
+ return hasOwn(events, rawSuffix) ? events[rawSuffix] : rawSuffix;
86
113
  }
87
- function claimHxOnAttribute(elt, attrName, lang, events) {
114
+ function claimHxOnAttribute(elt, attrName, lang, events, options = {}) {
88
115
  if (!executor) return false;
89
116
  const colon = attrName.indexOf(":");
90
117
  if (colon <= 0) return false;
91
118
  const eventName = eventNameForSuffix(attrName.slice(colon + 1), events);
92
- const already = claimed.get(elt);
93
- if (already?.has(eventName)) {
94
- if (attrName.startsWith("hx-on:") && elt.hasAttribute(attrName)) {
119
+ const canonical = attrName.startsWith("hx-on:");
120
+ const body = elt.getAttribute(attrName) ?? "";
121
+ let claims = claimed.get(elt);
122
+ const existing = claims?.get(eventName);
123
+ if (existing) {
124
+ if (existing.attrName === attrName) {
125
+ if (existing.body !== body) {
126
+ existing.body = body;
127
+ existing.translated = null;
128
+ }
129
+ if (canonical && neutralizeOnClaim && elt.hasAttribute(attrName)) {
130
+ elt.removeAttribute(attrName);
131
+ return true;
132
+ }
133
+ return false;
134
+ }
135
+ if (canonical && (options.adapterCreated || neutralizeOnClaim) && elt.hasAttribute(attrName)) {
95
136
  elt.removeAttribute(attrName);
96
137
  return true;
97
138
  }
98
139
  return false;
99
140
  }
100
- const body = elt.getAttribute(attrName) ?? "";
101
- let translatedBody = null;
141
+ const claim = { attrName, lang, body, translated: null };
102
142
  elt.addEventListener(eventName, (evt) => {
103
143
  if (!executor) return;
104
- if (translatedBody === null) {
105
- translatedBody = lang !== "en" && translator ? translator(body, lang) : body;
144
+ if (claim.translated === null) {
145
+ claim.translated = claim.lang !== "en" && translator ? translator(claim.body, claim.lang) : claim.body;
106
146
  }
107
147
  try {
108
- executor(translatedBody, elt, evt);
148
+ executor(claim.translated, elt, evt);
109
149
  } catch (err) {
110
150
  if (typeof console !== "undefined") {
111
151
  console.error(`[htmx-i18n] hx-on body execution failed (${attrName} \u2192 ${eventName})`, err);
112
152
  }
113
153
  }
114
154
  });
115
- if (attrName.startsWith("hx-on:")) elt.removeAttribute(attrName);
116
- if (already) {
117
- already.add(eventName);
118
- } else {
119
- claimed.set(elt, /* @__PURE__ */ new Set([eventName]));
155
+ if (!claims) {
156
+ claims = /* @__PURE__ */ new Map();
157
+ claimed.set(elt, claims);
120
158
  }
159
+ claims.set(eventName, claim);
160
+ if (canonical && neutralizeOnClaim) elt.removeAttribute(attrName);
121
161
  return true;
122
162
  }
123
163
  function autoDetectBodyHooks(win) {
@@ -138,6 +178,7 @@ function resetBodyHooks() {
138
178
  translator = null;
139
179
  hookChangeListeners.clear();
140
180
  claimed = /* @__PURE__ */ new WeakMap();
181
+ neutralizeOnClaim = true;
141
182
  }
142
183
 
143
184
  // src/resolver.ts
@@ -189,19 +230,36 @@ function installResolverMode(htmx) {
189
230
  }
190
231
 
191
232
  // src/canonicalize.ts
233
+ var hasOwn2 = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
192
234
  var NS_RE = /^(?:hx|sse|ws)-/;
235
+ var TOP_LEVEL_COMMA_RE = /,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/;
236
+ var mirrorSplit = (value) => value.split(TOP_LEVEL_COMMA_RE);
237
+ var splitSpecs = mirrorSplit;
238
+ function setTriggerSpecSplitter(fn) {
239
+ splitSpecs = fn ?? mirrorSplit;
240
+ }
241
+ function isTriggerAttr(name) {
242
+ return name === "hx-trigger" || name.startsWith("hx-trigger:");
243
+ }
193
244
  function translateTriggerValue(value, events) {
194
- return value.split(",").map((spec) => {
195
- const trimmed = spec.trim();
196
- if (!trimmed) return trimmed;
197
- const parts = trimmed.split(/\s+/);
198
- const m = parts[0].match(/^([^[\s]+)(\[.*)?$/);
199
- if (m) {
200
- const translated = events[m[1]];
201
- if (translated) parts[0] = translated + (m[2] ?? "");
202
- }
203
- return parts.join(" ");
204
- }).join(", ");
245
+ return splitSpecs(value).map(
246
+ (spec) => spec.replace(
247
+ /^(\s*)([^[\s]+)/,
248
+ (whole, ws, evt) => hasOwn2(events, evt) ? ws + events[evt] : whole
249
+ )
250
+ ).join(",");
251
+ }
252
+ var created = /* @__PURE__ */ new WeakMap();
253
+ function markCreated(elt, name) {
254
+ let names = created.get(elt);
255
+ if (!names) {
256
+ names = /* @__PURE__ */ new Set();
257
+ created.set(elt, names);
258
+ }
259
+ names.add(name);
260
+ }
261
+ function wasCreatedByAdapter(elt, name) {
262
+ return created.get(elt)?.has(name) ?? false;
205
263
  }
206
264
  function canonicalizeElement(elt) {
207
265
  if (!elt.attributes || elt.attributes.length === 0) return false;
@@ -223,23 +281,43 @@ function canonicalizeElement(elt) {
223
281
  }
224
282
  const attrs = vocab?.attrs ?? {};
225
283
  const events = vocab?.events ?? {};
284
+ let hasEvents = false;
285
+ for (const _ in events) {
286
+ hasEvents = true;
287
+ break;
288
+ }
226
289
  let changed = false;
227
290
  for (const attr of Array.from(elt.attributes)) {
228
291
  const name = attr.name;
229
292
  if (!NS_RE.test(name)) continue;
230
293
  if (executorMode && name.startsWith("hx-on:")) {
231
- if (claimHxOnAttribute(elt, name, lang, events)) changed = true;
294
+ if (claimHxOnAttribute(elt, name, lang, events, {
295
+ adapterCreated: wasCreatedByAdapter(elt, name)
296
+ })) {
297
+ changed = true;
298
+ }
299
+ continue;
300
+ }
301
+ if (isTriggerAttr(name)) {
302
+ if (hasEvents) {
303
+ const translated = translateTriggerValue(attr.value, events);
304
+ if (translated !== attr.value) {
305
+ elt.setAttribute(name, translated);
306
+ changed = true;
307
+ }
308
+ }
232
309
  continue;
233
310
  }
234
- let canonical = attrs[name];
311
+ let canonical = hasOwn2(attrs, name) ? attrs[name] : void 0;
235
312
  let colonBase;
236
313
  if (!canonical) {
237
314
  const colon = name.indexOf(":");
238
315
  if (colon > 0) {
239
- colonBase = attrs[name.slice(0, colon)];
316
+ const base = name.slice(0, colon);
317
+ colonBase = hasOwn2(attrs, base) ? attrs[base] : void 0;
240
318
  if (colonBase) {
241
319
  const suffix = name.slice(colon + 1);
242
- canonical = `${colonBase}:${events[suffix] ?? suffix}`;
320
+ canonical = colonBase === "hx-on" ? `${colonBase}:${hasOwn2(events, suffix) ? events[suffix] : suffix}` : `${colonBase}:${suffix}`;
243
321
  }
244
322
  }
245
323
  }
@@ -250,18 +328,11 @@ function canonicalizeElement(elt) {
250
328
  }
251
329
  if (canonical === name || elt.hasAttribute(canonical)) continue;
252
330
  let value = attr.value;
253
- if (canonical === "hx-trigger") value = translateTriggerValue(value, events);
331
+ if (isTriggerAttr(canonical)) value = translateTriggerValue(value, events);
254
332
  elt.setAttribute(canonical, value);
333
+ markCreated(elt, canonical);
255
334
  changed = true;
256
335
  }
257
- const trigger = elt.getAttribute("hx-trigger");
258
- if (trigger !== null && Object.keys(events).length > 0) {
259
- const translated = translateTriggerValue(trigger, events);
260
- if (translated !== trigger) {
261
- elt.setAttribute("hx-trigger", translated);
262
- changed = true;
263
- }
264
- }
265
336
  return changed;
266
337
  }
267
338
  function canonicalizeTree(root) {
@@ -280,9 +351,24 @@ function canonicalizeTree(root) {
280
351
 
281
352
  // src/extension.ts
282
353
  var EXTENSION_NAME = "lokascript-i18n";
283
- function createExtension() {
354
+ function isHtmxBindableHxOn(name, prefixes, mc) {
355
+ for (const p of prefixes) {
356
+ if (name === p) return true;
357
+ if (name.startsWith(p) && name.charAt(p.length) === mc) return true;
358
+ }
359
+ return false;
360
+ }
361
+ function createExtension(htmx) {
362
+ const cfg = htmx?.config;
363
+ const prefix = cfg && "prefix" in cfg ? cfg.prefix ?? "" : "data-hx-";
364
+ const mc = cfg?.metaCharacter || ":";
365
+ const hxOnPrefixes = prefix ? ["hx-on", prefix + "on"] : ["hx-on"];
284
366
  return {
285
- // htmx v4 (verified on 4.0.0-beta5): fires on each process() root
367
+ init(internalAPI) {
368
+ const split = internalAPI?.HCON?.split;
369
+ if (typeof split === "function") setTriggerSpecSplitter(split.bind(internalAPI.HCON));
370
+ },
371
+ // htmx v4 (verified on 4.0.0): fires on each process() root
286
372
  // before element init and hx-on binding.
287
373
  htmx_before_process(elt) {
288
374
  canonicalizeTree(elt);
@@ -291,6 +377,36 @@ function createExtension() {
291
377
  htmx_before_process_node(elt) {
292
378
  canonicalizeTree(elt);
293
379
  },
380
+ // htmx v4 (verified on 4.0.0): fires per node carrying an
381
+ // hx-on-family attribute, cancelable — returning false makes htmx
382
+ // skip JS-binding that node entirely. This is executor mode's
383
+ // zero-mutation double-execution guard, decided from what the sweep
384
+ // RECORDED on the node (not from whether an executor is set now):
385
+ //
386
+ // - nothing claimed on this node → htmx proceeds as usual;
387
+ // - every hx-on attribute htmx would bind here is claimed → cancel,
388
+ // the authored attributes stay in the DOM;
389
+ // - the node also carries forms htmx must bind and the adapter never
390
+ // claims (the legacy composite `hx-on="event -> code"`, the
391
+ // `config.prefix` spelling, a custom meta character) → per-node
392
+ // cancellation would kill those too, so fall back to removing the
393
+ // claimed canonical attrs and let htmx bind the rest.
394
+ htmx_before_on_init(elt) {
395
+ const claimedNames = claimedAttrNames(elt);
396
+ if (claimedNames.size === 0) return void 0;
397
+ let unclaimedBindable = false;
398
+ for (const attr of Array.from(elt.attributes)) {
399
+ const name = attr.name;
400
+ if (claimedNames.has(name)) continue;
401
+ if (isHtmxBindableHxOn(name, hxOnPrefixes, mc)) {
402
+ unclaimedBindable = true;
403
+ break;
404
+ }
405
+ }
406
+ if (!unclaimedBindable) return false;
407
+ removeClaimedCanonicalAttrs(elt);
408
+ return void 0;
409
+ },
294
410
  // htmx v1/v2 fallback: single event dispatcher.
295
411
  onEvent(name, evt) {
296
412
  if (name !== "htmx:beforeProcessNode") return;
@@ -302,13 +418,23 @@ function createExtension() {
302
418
  }
303
419
  function registerWith(htmx) {
304
420
  if (!htmx) return null;
305
- const ext = createExtension();
421
+ const ext = createExtension(htmx);
306
422
  if (typeof htmx.registerExtension === "function") {
307
- htmx.registerExtension(EXTENSION_NAME, ext);
423
+ if (htmx.registerExtension(EXTENSION_NAME, ext) === false) {
424
+ if (typeof console !== "undefined") {
425
+ console.warn(
426
+ `[htmx-i18n] htmx.registerExtension("${EXTENSION_NAME}") returned false \u2014 the extension is not registered. If htmx.config.extensions (the <meta name="htmx-config"> allowlist) is set, add "${EXTENSION_NAME}" to it. Swapped-in content will not be canonicalized; executor-mode claims fall back to removing canonical hx-on:* attrs.`
427
+ );
428
+ }
429
+ setNeutralizeOnClaim(true);
430
+ return null;
431
+ }
432
+ setNeutralizeOnClaim(false);
308
433
  return "v4";
309
434
  }
310
435
  if (typeof htmx.defineExtension === "function") {
311
436
  htmx.defineExtension(EXTENSION_NAME, ext);
437
+ setNeutralizeOnClaim(true);
312
438
  return "v2";
313
439
  }
314
440
  return null;
@@ -317,54 +443,73 @@ function installAutoSweep(doc = document) {
317
443
  const sweep = () => {
318
444
  canonicalizeTree(doc.body ?? doc.documentElement);
319
445
  };
320
- let removeDomListener = null;
321
- if (doc.readyState === "loading") {
322
- const onReady = () => sweep();
323
- doc.addEventListener("DOMContentLoaded", onReady, { once: true });
324
- removeDomListener = () => doc.removeEventListener("DOMContentLoaded", onReady);
325
- } else {
446
+ let ready = doc.readyState === "complete";
447
+ let removeReadyListeners = null;
448
+ if (ready) {
326
449
  sweep();
450
+ } else {
451
+ const onReady = () => {
452
+ if (ready) return;
453
+ ready = true;
454
+ removeReadyListeners?.();
455
+ sweep();
456
+ };
457
+ const win = doc.defaultView;
458
+ doc.addEventListener("DOMContentLoaded", onReady);
459
+ win?.addEventListener("load", onReady);
460
+ removeReadyListeners = () => {
461
+ doc.removeEventListener("DOMContentLoaded", onReady);
462
+ win?.removeEventListener("load", onReady);
463
+ };
327
464
  }
328
465
  const unsubscribeVocab = onVocabUpdate(() => {
329
- if (doc.readyState !== "loading") sweep();
466
+ if (ready) sweep();
330
467
  });
331
468
  const unsubscribeBodyHooks = onBodyHooksChanged(() => {
332
- if (doc.readyState !== "loading") sweep();
469
+ if (ready) sweep();
333
470
  });
334
471
  return () => {
335
- removeDomListener?.();
472
+ removeReadyListeners?.();
336
473
  unsubscribeVocab();
337
474
  unsubscribeBodyHooks();
338
475
  };
339
476
  }
340
477
  export {
341
478
  EXTENSION_NAME,
479
+ TOP_LEVEL_COMMA_RE,
342
480
  additionalAttributeSelectors,
343
481
  attributeResolver,
344
482
  autoDetectBodyHooks,
345
483
  canonicalizeElement,
346
484
  canonicalizeTree,
347
485
  claimHxOnAttribute,
486
+ claimedAttrNames,
348
487
  createExtension,
349
488
  hasAnyVocab,
350
489
  hasBodyExecutor,
351
490
  hasBodyTranslator,
352
491
  installAutoSweep,
353
492
  installResolverMode,
493
+ isClaimed,
354
494
  isLangRegistered,
355
495
  isResolverMode,
356
496
  langOf,
497
+ neutralizesOnClaim,
357
498
  normLang,
358
499
  onBodyHooksChanged,
359
500
  onVocabUpdate,
360
501
  register,
361
502
  registerWith,
503
+ removeClaimedCanonicalAttrs,
362
504
  resetBodyHooks,
363
505
  resetRegistry,
364
506
  setBodyExecutor,
365
507
  setBodyTranslator,
508
+ setNeutralizeOnClaim,
366
509
  setResolverMode,
510
+ setTriggerSpecSplitter,
367
511
  translateTriggerValue,
368
- vocabFor
512
+ vocabFor,
513
+ wasCreatedByAdapter
369
514
  };
370
515
  //# sourceMappingURL=index.js.map