@lokascript/htmx-adapter 3.0.0 → 3.1.1
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/CHANGELOG.md +206 -3
- package/README.md +44 -16
- package/dist/htmx-i18n.global.js +1 -1
- package/dist/htmx-i18n.global.js.map +1 -1
- package/dist/index.cjs +207 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +141 -31
- package/dist/index.d.ts +141 -31
- package/dist/index.js +198 -53
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/browser.ts +20 -5
- package/src/canonicalize.ts +131 -45
- package/src/extension.ts +175 -26
- package/src/hx-on.ts +153 -43
- package/src/index.ts +15 -1
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
|
|
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 {
|
|
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
|
-
/**
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
*
|
|
88
|
-
* (e.g. a vocab <script> below htmx,
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
263
|
+
if (ready) sweep();
|
|
115
264
|
});
|
|
116
265
|
|
|
117
266
|
return () => {
|
|
118
|
-
|
|
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`)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
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]
|
|
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
|
|
97
|
-
* listener and
|
|
98
|
-
*
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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 (
|
|
136
|
-
|
|
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(
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|
|
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';
|