@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/src/extension.ts CHANGED
@@ -4,18 +4,25 @@
4
4
  * Primary target is **htmx v4**, whose extensions register via
5
5
  * `htmx.registerExtension(name, ext)` and hook lifecycle events through
6
6
  * underscore-named methods (event name with `:` → `_`), each receiving
7
- * `(elt, detail)`. Verified against htmx 4.0.0-beta5 (see
7
+ * `(elt, detail)`. Verified against htmx 4.0.0 (see
8
8
  * test/browser/vendor/README.md): `process(root)` fires
9
9
  * `htmx:before:process` on the processed root — `document.body`
10
10
  * initially, each swapped-in subtree afterwards — BEFORE any element
11
11
  * init or hx-on binding, so canonicalizing in `htmx_before_process`
12
12
  * covers everything htmx will read. `htmx_before_process_node` is kept
13
13
  * as a defensive alias for other v4 prereleases that used per-node
14
- * naming; an unmatched key is inert.
14
+ * naming; an unmatched key is inert. The cancelable
15
+ * `htmx_before_on_init` hook (fires per hx-on-carrying node, after
16
+ * before:process; documented as cancelable in htmx 4.0.0's `htmx.d.ts`)
17
+ * lets executor mode keep claimed `hx-on:*` attributes in the DOM
18
+ * instead of removing them — see createExtension.
15
19
  *
16
20
  * A v2 fallback (`htmx.defineExtension` + `onEvent('htmx:beforeProcessNode')`)
17
21
  * is included because the localized attribute names are version-agnostic
18
- * data — but v2 support is best-effort, not a tested target.
22
+ * data — but v2 support is best-effort, not a tested target. Measured on
23
+ * 2.0.10: `processNode` binds `hx-on` (`processHxOnWildcard`) BEFORE it
24
+ * fires `htmx:beforeProcessNode` per node, so v2 has no pre-bind seam and
25
+ * executor-mode claims keep removing canonical `hx-on:*` attrs there.
19
26
  *
20
27
  * The extension hook alone is not enough for the *initial* page: script
21
28
  * order decides whether our sweep beats htmx's own DOMContentLoaded scan.
@@ -25,27 +32,90 @@
25
32
  * ahead of htmx's.
26
33
  */
27
34
 
28
- import { canonicalizeTree } from './canonicalize.js';
35
+ import { canonicalizeTree, setTriggerSpecSplitter } from './canonicalize.js';
29
36
  import { onVocabUpdate } from './registry.js';
30
- import { onBodyHooksChanged } from './hx-on.js';
37
+ import {
38
+ claimedAttrNames,
39
+ onBodyHooksChanged,
40
+ removeClaimedCanonicalAttrs,
41
+ setNeutralizeOnClaim,
42
+ } from './hx-on.js';
31
43
 
32
44
  export const EXTENSION_NAME = 'lokascript-i18n';
33
45
 
34
46
  /** Minimal shape of the htmx global we interact with. */
35
47
  export interface HtmxLike {
36
- /** htmx v4 registration entry point. */
37
- registerExtension?(name: string, extension: object): void;
48
+ /**
49
+ * htmx v4 registration entry point. Returns `false` when the name is
50
+ * rejected — `htmx.config.extensions` is a non-empty allowlist that
51
+ * omits it, or it was already registered. (`htmx.d.ts` types the
52
+ * return as `void`; the runtime, verified on 4.0.0, returns `false`.)
53
+ */
54
+ registerExtension?(name: string, extension: object): boolean | void;
38
55
  /** htmx v1/v2 registration entry point. */
39
56
  defineExtension?(name: string, extension: object): void;
57
+ /** htmx v4 config — the parts that decide which hx-on spellings it binds. */
58
+ config?: {
59
+ /** Alternate attribute prefix (`data-hx-` by default; `''` disables it). */
60
+ prefix?: string;
61
+ /** Separator between `hx-on` and the event name (`:` by default). */
62
+ metaCharacter?: string;
63
+ };
64
+ }
65
+
66
+ /** The slice of htmx v4's `internalAPI` the extension reads. */
67
+ interface InternalApiLike {
68
+ HCON?: { split?(value: string): string[] };
69
+ }
70
+
71
+ /**
72
+ * The hx-on attribute names htmx v4 will bind on a node, mirroring
73
+ * `#prefixes("hx-on")` + `#handleHxOnAttributes` (vendored 4.0.0):
74
+ * for each prefix `p` in [`hx-on`, `config.prefix + "on"`], the bare
75
+ * composite `p` (`hx-on="event -> code"`) and `p + metaCharacter + event`.
76
+ * `hx-online` / `data-hx-once` are NOT bound — the char after the prefix
77
+ * must be the meta character.
78
+ */
79
+ function isHtmxBindableHxOn(name: string, prefixes: string[], mc: string): boolean {
80
+ for (const p of prefixes) {
81
+ if (name === p) return true;
82
+ if (name.startsWith(p) && name.charAt(p.length) === mc) return true;
83
+ }
84
+ return false;
40
85
  }
41
86
 
42
87
  /**
43
88
  * Build the extension object. v4 hooks and the v2 `onEvent` callback are
44
- * both present — each API only reads the members it knows about.
89
+ * both present — each API only reads the members it knows about. Pass
90
+ * the htmx instance so the v4 hook can read `config.prefix` /
91
+ * `config.metaCharacter` (defaults are assumed otherwise).
92
+ *
93
+ * `init(internalAPI)` (v4 only) takes exactly one thing from the API:
94
+ * `HCON.split`, htmx's own top-level trigger-spec splitter, which
95
+ * replaces the regex mirror in canonicalize.ts for the lifetime of the
96
+ * page. The rest of the 4.0.0 surface — `attributeValue`,
97
+ * `parseTriggerSpecs`, `getAttributeObject`, `insertContent`, `morph`,
98
+ * `initSecurity`, `onTrigger`, `htmxProp`, `triggerHtmxEvent`,
99
+ * `executeJavaScript` — was evaluated and not used: `parseTriggerSpecs`
100
+ * is parse-only (no serializer, so translating through it would mean a
101
+ * hand-written spec serializer); `attributeValue`'s inheritance-aware
102
+ * reads buy nothing for a sweep that visits every element anyway; and
103
+ * `htmxProp(node).onInitialized = true` WOULD keep htmx's binder off a
104
+ * node, but couples to a private flag where `htmx:before:on:init` is a
105
+ * typed, documented, cancelable event — the hook below uses that.
45
106
  */
46
- export function createExtension(): object {
107
+ export function createExtension(htmx?: HtmxLike | null): object {
108
+ const cfg = htmx?.config;
109
+ const prefix = cfg && 'prefix' in cfg ? (cfg.prefix ?? '') : 'data-hx-';
110
+ const mc = cfg?.metaCharacter || ':';
111
+ const hxOnPrefixes = prefix ? ['hx-on', prefix + 'on'] : ['hx-on'];
112
+
47
113
  return {
48
- // htmx v4 (verified on 4.0.0-beta5): fires on each process() root
114
+ init(internalAPI?: InternalApiLike): void {
115
+ const split = internalAPI?.HCON?.split;
116
+ if (typeof split === 'function') setTriggerSpecSplitter(split.bind(internalAPI!.HCON));
117
+ },
118
+ // htmx v4 (verified on 4.0.0): fires on each process() root
49
119
  // before element init and hx-on binding.
50
120
  htmx_before_process(elt: Element): void {
51
121
  canonicalizeTree(elt);
@@ -54,6 +124,36 @@ export function createExtension(): object {
54
124
  htmx_before_process_node(elt: Element): void {
55
125
  canonicalizeTree(elt);
56
126
  },
127
+ // htmx v4 (verified on 4.0.0): fires per node carrying an
128
+ // hx-on-family attribute, cancelable — returning false makes htmx
129
+ // skip JS-binding that node entirely. This is executor mode's
130
+ // zero-mutation double-execution guard, decided from what the sweep
131
+ // RECORDED on the node (not from whether an executor is set now):
132
+ //
133
+ // - nothing claimed on this node → htmx proceeds as usual;
134
+ // - every hx-on attribute htmx would bind here is claimed → cancel,
135
+ // the authored attributes stay in the DOM;
136
+ // - the node also carries forms htmx must bind and the adapter never
137
+ // claims (the legacy composite `hx-on="event -> code"`, the
138
+ // `config.prefix` spelling, a custom meta character) → per-node
139
+ // cancellation would kill those too, so fall back to removing the
140
+ // claimed canonical attrs and let htmx bind the rest.
141
+ htmx_before_on_init(elt: Element): boolean | undefined {
142
+ const claimedNames = claimedAttrNames(elt);
143
+ if (claimedNames.size === 0) return undefined;
144
+ let unclaimedBindable = false;
145
+ for (const attr of Array.from(elt.attributes)) {
146
+ const name = attr.name;
147
+ if (claimedNames.has(name)) continue;
148
+ if (isHtmxBindableHxOn(name, hxOnPrefixes, mc)) {
149
+ unclaimedBindable = true;
150
+ break;
151
+ }
152
+ }
153
+ if (!unclaimedBindable) return false;
154
+ removeClaimedCanonicalAttrs(elt);
155
+ return undefined;
156
+ },
57
157
  // htmx v1/v2 fallback: single event dispatcher.
58
158
  onEvent(name: string, evt: CustomEvent & { target?: EventTarget | null }): void {
59
159
  if (name !== 'htmx:beforeProcessNode') return;
@@ -66,26 +166,62 @@ export function createExtension(): object {
66
166
 
67
167
  /**
68
168
  * Register the extension with an htmx global. Returns which API accepted
69
- * it (`'v4'` / `'v2'`) or `null` if the object exposes neither.
169
+ * it (`'v4'` / `'v2'`) or `null` if the object exposes neither, or if
170
+ * v4 rejected the registration.
171
+ *
172
+ * Only an ACCEPTED v4 registration turns claim-time neutralization off:
173
+ * that is the one runtime whose per-node `htmx:before:on:init` hook can
174
+ * keep htmx off a preserved `hx-on:*` attribute. Everything else keeps
175
+ * the hook-independent removal guard.
70
176
  */
71
177
  export function registerWith(htmx: HtmxLike | undefined | null): 'v4' | 'v2' | null {
72
178
  if (!htmx) return null;
73
- const ext = createExtension();
179
+ const ext = createExtension(htmx);
74
180
  if (typeof htmx.registerExtension === 'function') {
75
- htmx.registerExtension(EXTENSION_NAME, ext);
181
+ // v4 REJECTS the registration (returns false) when
182
+ // `htmx.config.extensions` is an allowlist that omits us, or on a
183
+ // duplicate name. Nothing we return from a hook can then run, so
184
+ // claim-time neutralization must stay on — turning it off here would
185
+ // leave claimed hx-on:* bodies in the DOM for htmx to JS-eval.
186
+ if (htmx.registerExtension(EXTENSION_NAME, ext) === false) {
187
+ if (typeof console !== 'undefined') {
188
+ console.warn(
189
+ `[htmx-i18n] htmx.registerExtension("${EXTENSION_NAME}") returned false — the ` +
190
+ 'extension is not registered. If htmx.config.extensions (the <meta name="htmx-config"> ' +
191
+ `allowlist) is set, add "${EXTENSION_NAME}" to it. Swapped-in content will not be ` +
192
+ 'canonicalized; executor-mode claims fall back to removing canonical hx-on:* attrs.'
193
+ );
194
+ }
195
+ setNeutralizeOnClaim(true);
196
+ return null;
197
+ }
198
+ setNeutralizeOnClaim(false);
76
199
  return 'v4';
77
200
  }
78
201
  if (typeof htmx.defineExtension === 'function') {
79
202
  htmx.defineExtension(EXTENSION_NAME, ext);
203
+ // v2 binds hx-on before it fires beforeProcessNode — no pre-bind
204
+ // seam, so removal at claim time stays the guard.
205
+ setNeutralizeOnClaim(true);
80
206
  return 'v2';
81
207
  }
82
208
  return null;
83
209
  }
84
210
 
85
211
  /**
86
- * Sweep the whole document now (if parsed) or on DOMContentLoaded, and
87
- * re-sweep whenever a vocab module registers after the initial sweep
88
- * (e.g. a vocab <script> below htmx, or dynamic registration).
212
+ * Sweep the whole document once the DOM is parsed, and re-sweep whenever
213
+ * a vocab module registers or the body executor changes after that
214
+ * (e.g. a vocab <script> below htmx, a lazily loaded _hyperscript, or
215
+ * dynamic registration).
216
+ *
217
+ * "Parsed" means DOMContentLoaded has FIRED (or `readyState` is already
218
+ * `'complete'`) — not merely `readyState !== 'loading'`. During
219
+ * DOMContentLoaded dispatch `readyState` is already `'interactive'`, and
220
+ * a `defer`/module adapter runs at `'interactive'` before htmx exists;
221
+ * sweeping then would claim in the default remove mode before
222
+ * `registerWith` had a chance to turn neutralization off. A `load`
223
+ * listener covers a script injected after DOMContentLoaded but before
224
+ * `'complete'` (DOMContentLoaded will not fire again for it).
89
225
  *
90
226
  * Returns a cleanup function (mainly for tests).
91
227
  */
@@ -94,28 +230,41 @@ export function installAutoSweep(doc: Document = document): () => void {
94
230
  canonicalizeTree(doc.body ?? doc.documentElement);
95
231
  };
96
232
 
97
- let removeDomListener: (() => void) | null = null;
98
- if (doc.readyState === 'loading') {
99
- const onReady = (): void => sweep();
100
- doc.addEventListener('DOMContentLoaded', onReady, { once: true });
101
- removeDomListener = () => doc.removeEventListener('DOMContentLoaded', onReady);
102
- } else {
233
+ let ready = doc.readyState === 'complete';
234
+ let removeReadyListeners: (() => void) | null = null;
235
+ if (ready) {
103
236
  sweep();
237
+ } else {
238
+ const onReady = (): void => {
239
+ if (ready) return;
240
+ ready = true;
241
+ removeReadyListeners?.();
242
+ sweep();
243
+ };
244
+ const win = doc.defaultView;
245
+ doc.addEventListener('DOMContentLoaded', onReady);
246
+ win?.addEventListener('load', onReady);
247
+ removeReadyListeners = () => {
248
+ doc.removeEventListener('DOMContentLoaded', onReady);
249
+ win?.removeEventListener('load', onReady);
250
+ };
104
251
  }
105
252
 
106
253
  const unsubscribeVocab = onVocabUpdate(() => {
107
- if (doc.readyState !== 'loading') sweep();
254
+ if (ready) sweep();
108
255
  });
109
256
 
110
257
  // A body executor configured after the initial sweep flips the hx-on
111
258
  // family into executor mode — re-sweep so already-canonicalized
112
- // hx-on:* attrs get claimed (listener installed, attr removed).
259
+ // hx-on:* attrs get claimed (listener installed; an adapter-created
260
+ // canonical sibling is removed, an authored one is left for the v4
261
+ // hook or removed by the claim, per the neutralization setting).
113
262
  const unsubscribeBodyHooks = onBodyHooksChanged(() => {
114
- if (doc.readyState !== 'loading') sweep();
263
+ if (ready) sweep();
115
264
  });
116
265
 
117
266
  return () => {
118
- removeDomListener?.();
267
+ removeReadyListeners?.();
119
268
  unsubscribeVocab();
120
269
  unsubscribeBodyHooks();
121
270
  };
package/src/hx-on.ts CHANGED
@@ -19,11 +19,21 @@
19
19
  * - Localized-named attrs (`hx-en:clic`) stay verbatim in the DOM — htmx
20
20
  * never recognized them anyway — and NO canonical `hx-on:*` sibling is
21
21
  * created.
22
- * - Canonical-named attrs (`hx-on:click`) are REMOVED after claiming.
23
- * This is the one place the adapter deletes an authored attribute: if
24
- * it stayed, htmx would eval the hyperscript body as JS — a console
25
- * error plus a double-execution attempt on every fire. Documented as
26
- * the executor-mode exception in the README.
22
+ * - Canonical-named attrs (`hx-on:click`) must be kept away from htmx's
23
+ * own binder: if htmx bound them, it would eval the hyperscript body
24
+ * as JS — a console error plus a double-execution attempt on every
25
+ * fire. A claim RECORDS the attribute (name + body) on the element;
26
+ * what keeps htmx off it is decided by whoever owns the runtime:
27
+ * - htmx v4, extension accepted: the extension's cancelable
28
+ * `htmx:before:on:init` hook consults the record per node and
29
+ * cancels htmx's binding — the authored attribute stays in the DOM
30
+ * (see extension.ts). `registerWith` turns claim-time removal OFF
31
+ * only after the registration actually succeeded.
32
+ * - everywhere else (htmx v2, a rejected v4 registration, no htmx):
33
+ * `neutralizeOnClaim` is ON (the default) and the canonical-named
34
+ * attribute is removed at claim time — the only guard that does not
35
+ * depend on a hook being installed, documented as the executor-mode
36
+ * exception in the README.
27
37
  *
28
38
  * With no executor set (the default), none of this runs and bodies keep
29
39
  * upstream JS semantics — the behavior-preservation invariant.
@@ -32,28 +42,66 @@
32
42
  * loads after the initial sweep still applies. Both auto-detection
33
43
  * (`autoDetectBodyHooks`) and manual configuration are supported; the
34
44
  * executor is also re-read at fire time so replacing it takes effect on
35
- * live listeners.
45
+ * live listeners. The body is re-read on every re-claim, so
46
+ * `htmx.process(elt, true)` after editing the attribute runs the new body.
36
47
  */
37
48
 
38
49
  export type BodyExecutor = (code: string, elt: Element, evt: Event) => unknown;
39
50
  export type BodyTranslator = (body: string, lang: string) => string;
40
51
 
52
+ /** Own-key lookup: vocab maps are plain objects, and `constructor` is not an event. */
53
+ const hasOwn = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
54
+
41
55
  let executor: BodyExecutor | null = null;
42
56
  let translator: BodyTranslator | null = null;
43
57
 
44
58
  /** Listeners notified when the executor is set/cleared (drives re-sweeps). */
45
59
  const hookChangeListeners = new Set<() => void>();
46
60
 
61
+ /** One claimed hx-on attribute on an element. */
62
+ interface Claim {
63
+ /** The attribute the listener was installed for (`hx-on:click` / `hx-en:clic`). */
64
+ attrName: string;
65
+ /** Element language at claim time — drives lazy body translation. */
66
+ lang: string;
67
+ /** Authored body, refreshed on re-claim when the attribute changed. */
68
+ body: string;
69
+ /** Memoized translation of `body` (null = not translated yet). */
70
+ translated: string | null;
71
+ }
72
+
73
+ /**
74
+ * Per-element claims keyed by resolved EVENT name (sweep idempotency).
75
+ * Keyed by event rather than attribute name so a localized attr and its
76
+ * canonical form never both install a listener. When both are genuinely
77
+ * authored, DOM attribute order decides which body wins; an
78
+ * adapter-created canonical sibling (a no-executor sweep copied
79
+ * `hx-en:clic` to `hx-on:click`, then an executor arrived) is removed
80
+ * on the re-claim — it was never authored, so the never-mutate rule does
81
+ * not cover it, and leaving it would hand htmx a foreign-language body.
82
+ */
83
+ let claimed = new WeakMap<Element, Map<string, Claim>>();
84
+
47
85
  /**
48
- * Per-element set of already-claimed EVENT names (sweep idempotency).
49
- * Keyed by resolved event name rather than attribute name so a localized
50
- * attr and its canonical form never both install a listener — e.g. after
51
- * a v1 (no-executor) sweep created an `hx-on:click` sibling of
52
- * `hx-en:clic` and an executor registered later, the re-sweep claims one
53
- * of them and neutralizes the other. When both are genuinely authored,
54
- * DOM attribute order decides which body wins.
86
+ * Whether a claim on a canonical-named `hx-on:*` attribute deletes it.
87
+ *
88
+ * ON (default) is the hook-independent guard. `registerWith` turns it
89
+ * OFF only once htmx v4 has actually accepted the extension, because
90
+ * only then does the per-node `htmx_before_on_init` cancellation exist
91
+ * to keep htmx off the preserved attribute. Consumers wiring
92
+ * `createExtension()` into `htmx.registerExtension` themselves must
93
+ * call `setNeutralizeOnClaim(false)` after a successful registration
94
+ * to get the same zero-mutation behaviour.
55
95
  */
56
- let claimed = new WeakMap<Element, Set<string>>();
96
+ let neutralizeOnClaim = true;
97
+
98
+ export function setNeutralizeOnClaim(enabled: boolean): void {
99
+ neutralizeOnClaim = enabled;
100
+ }
101
+
102
+ export function neutralizesOnClaim(): boolean {
103
+ return neutralizeOnClaim;
104
+ }
57
105
 
58
106
  /** Configure the body executor. Setting/clearing it notifies subscribers. */
59
107
  export function setBodyExecutor(fn: BodyExecutor | null): void {
@@ -82,20 +130,62 @@ export function onBodyHooksChanged(listener: () => void): () => void {
82
130
  };
83
131
  }
84
132
 
133
+ /** True when the adapter has claimed at least one hx-on attribute on `elt`. */
134
+ export function isClaimed(elt: Element): boolean {
135
+ return (claimed.get(elt)?.size ?? 0) > 0;
136
+ }
137
+
138
+ /** Names of the attributes claimed on `elt` (canonical and localized). */
139
+ export function claimedAttrNames(elt: Element): Set<string> {
140
+ const names = new Set<string>();
141
+ const claims = claimed.get(elt);
142
+ if (claims) for (const c of claims.values()) names.add(c.attrName);
143
+ return names;
144
+ }
145
+
146
+ /**
147
+ * Remove the claimed CANONICAL-named (`hx-on:*`) attributes from `elt`.
148
+ * The per-node fallback for runtimes that will bind the node anyway
149
+ * (v4 mixed nodes). Localized names are invisible to htmx and stay.
150
+ * Returns how many were removed.
151
+ */
152
+ export function removeClaimedCanonicalAttrs(elt: Element): number {
153
+ let n = 0;
154
+ for (const name of claimedAttrNames(elt)) {
155
+ if (name.startsWith('hx-on:') && elt.hasAttribute(name)) {
156
+ elt.removeAttribute(name);
157
+ n++;
158
+ }
159
+ }
160
+ return n;
161
+ }
162
+
85
163
  /**
86
164
  * Resolve the DOM event name for an hx-on attribute suffix.
87
165
  * `hx-on::after-swap` shorthand (leading `:`) means the `htmx:` namespace;
88
- * plain suffixes translate through the vocab events map.
166
+ * plain suffixes translate through the vocab events map (own keys only —
167
+ * `constructor` is not an event).
89
168
  */
90
169
  function eventNameForSuffix(rawSuffix: string, events: Record<string, string>): string {
91
170
  if (rawSuffix.startsWith(':')) return `htmx${rawSuffix}`;
92
- return events[rawSuffix] ?? rawSuffix;
171
+ return hasOwn(events, rawSuffix) ? events[rawSuffix] : rawSuffix;
172
+ }
173
+
174
+ export interface ClaimOptions {
175
+ /**
176
+ * The attribute was written by the adapter's own canonicalization (a
177
+ * no-executor sweep's `hx-on:*` copy of a localized attr), not by the
178
+ * author. Such a duplicate is always removed on claim.
179
+ */
180
+ adapterCreated?: boolean;
93
181
  }
94
182
 
95
183
  /**
96
- * Claim one hx-on-family attribute on an element: install the executor
97
- * listener and neutralize htmx's view of it. Returns true if the claim
98
- * happened now (false when no executor, already claimed, or malformed).
184
+ * Claim one hx-on-family attribute on an element: record it, install the
185
+ * executor listener, and — when claim-time neutralization is on — remove
186
+ * a canonical-named attribute so htmx never JS-evals it. Returns true if
187
+ * the element changed or a listener was installed now (false when no
188
+ * executor, already claimed for that event, or malformed).
99
189
  *
100
190
  * `lang` is the element's language at claim time — used for lazy body
101
191
  * translation. `events` is the language's event-name map (for the
@@ -105,7 +195,8 @@ export function claimHxOnAttribute(
105
195
  elt: Element,
106
196
  attrName: string,
107
197
  lang: string,
108
- events: Record<string, string>
198
+ events: Record<string, string>,
199
+ options: ClaimOptions = {}
109
200
  ): boolean {
110
201
  if (!executor) return false;
111
202
 
@@ -113,30 +204,46 @@ export function claimHxOnAttribute(
113
204
  if (colon <= 0) return false; // colon-form only; legacy composite hx-on="…" unsupported
114
205
 
115
206
  const eventName = eventNameForSuffix(attrName.slice(colon + 1), events);
116
- const already = claimed.get(elt);
117
- if (already?.has(eventName)) {
118
- // Duplicate claim for an already-claimed event — no second listener,
119
- // but canonical-named attrs still get neutralized so htmx never
120
- // JS-evals them.
121
- if (attrName.startsWith('hx-on:') && elt.hasAttribute(attrName)) {
207
+ const canonical = attrName.startsWith('hx-on:');
208
+ const body = elt.getAttribute(attrName) ?? '';
209
+
210
+ let claims = claimed.get(elt);
211
+ const existing = claims?.get(eventName);
212
+ if (existing) {
213
+ if (existing.attrName === attrName) {
214
+ // Re-claim of the same attribute (re-sweep, or a force re-process
215
+ // after the author edited it): refresh the body, no new listener.
216
+ if (existing.body !== body) {
217
+ existing.body = body;
218
+ existing.translated = null;
219
+ }
220
+ if (canonical && neutralizeOnClaim && elt.hasAttribute(attrName)) {
221
+ elt.removeAttribute(attrName);
222
+ return true;
223
+ }
224
+ return false;
225
+ }
226
+ // A second attribute for an already-claimed event (localized +
227
+ // canonical naming the same event). No second listener. A canonical
228
+ // duplicate is removed when it is adapter-created (never authored)
229
+ // or when claim-time neutralization is on; an authored one stays
230
+ // for the v4 hook to cancel.
231
+ if (canonical && (options.adapterCreated || neutralizeOnClaim) && elt.hasAttribute(attrName)) {
122
232
  elt.removeAttribute(attrName);
123
233
  return true;
124
234
  }
125
235
  return false;
126
236
  }
127
237
 
128
- const body = elt.getAttribute(attrName) ?? '';
129
-
130
- // Lazy, memoized translation: translator may register after the sweep,
131
- // and repeated fires shouldn't re-translate.
132
- let translatedBody: string | null = null;
238
+ const claim: Claim = { attrName, lang, body, translated: null };
133
239
  elt.addEventListener(eventName, evt => {
134
240
  if (!executor) return; // executor cleared after claim — go quiet
135
- if (translatedBody === null) {
136
- translatedBody = lang !== 'en' && translator ? translator(body, lang) : body;
241
+ if (claim.translated === null) {
242
+ claim.translated =
243
+ claim.lang !== 'en' && translator ? translator(claim.body, claim.lang) : claim.body;
137
244
  }
138
245
  try {
139
- executor(translatedBody, elt, evt);
246
+ executor(claim.translated, elt, evt);
140
247
  } catch (err) {
141
248
  if (typeof console !== 'undefined') {
142
249
  console.error(`[htmx-i18n] hx-on body execution failed (${attrName} → ${eventName})`, err);
@@ -144,16 +251,18 @@ export function claimHxOnAttribute(
144
251
  }
145
252
  });
146
253
 
147
- // Canonical-named claims are removed so htmx never JS-evals the
148
- // hyperscript body (double-execution guard). Localized names are
149
- // invisible to htmx and stay verbatim.
150
- if (attrName.startsWith('hx-on:')) elt.removeAttribute(attrName);
151
-
152
- if (already) {
153
- already.add(eventName);
154
- } else {
155
- claimed.set(elt, new Set([eventName]));
254
+ if (!claims) {
255
+ claims = new Map();
256
+ claimed.set(elt, claims);
156
257
  }
258
+ claims.set(eventName, claim);
259
+
260
+ // Hook-independent guard: with neutralization on, a canonical-named
261
+ // claim is deleted so htmx never JS-evals the hyperscript body. With it
262
+ // off (v4 extension accepted) the attribute stays and the extension's
263
+ // before:on:init hook cancels htmx's binding instead. Localized names
264
+ // are invisible to htmx and always stay verbatim.
265
+ if (canonical && neutralizeOnClaim) elt.removeAttribute(attrName);
157
266
  return true;
158
267
  }
159
268
 
@@ -194,4 +303,5 @@ export function resetBodyHooks(): void {
194
303
  translator = null;
195
304
  hookChangeListeners.clear();
196
305
  claimed = new WeakMap();
306
+ neutralizeOnClaim = true;
197
307
  }
package/src/index.ts CHANGED
@@ -28,7 +28,15 @@ export {
28
28
  type HtmxVocab,
29
29
  type VocabPayload,
30
30
  } from './registry.js';
31
- export { canonicalizeElement, canonicalizeTree, translateTriggerValue } from './canonicalize.js';
31
+ export {
32
+ canonicalizeElement,
33
+ canonicalizeTree,
34
+ translateTriggerValue,
35
+ setTriggerSpecSplitter,
36
+ wasCreatedByAdapter,
37
+ TOP_LEVEL_COMMA_RE,
38
+ type TriggerSpecSplitter,
39
+ } from './canonicalize.js';
32
40
  export {
33
41
  EXTENSION_NAME,
34
42
  createExtension,
@@ -50,8 +58,14 @@ export {
50
58
  hasBodyTranslator,
51
59
  onBodyHooksChanged,
52
60
  claimHxOnAttribute,
61
+ claimedAttrNames,
62
+ isClaimed,
63
+ removeClaimedCanonicalAttrs,
64
+ setNeutralizeOnClaim,
65
+ neutralizesOnClaim,
53
66
  autoDetectBodyHooks,
54
67
  resetBodyHooks,
55
68
  type BodyExecutor,
56
69
  type BodyTranslator,
70
+ type ClaimOptions,
57
71
  } from './hx-on.js';