@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/dist/index.cjs CHANGED
@@ -21,33 +21,41 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
23
  EXTENSION_NAME: () => EXTENSION_NAME,
24
+ TOP_LEVEL_COMMA_RE: () => TOP_LEVEL_COMMA_RE,
24
25
  additionalAttributeSelectors: () => additionalAttributeSelectors,
25
26
  attributeResolver: () => attributeResolver,
26
27
  autoDetectBodyHooks: () => autoDetectBodyHooks,
27
28
  canonicalizeElement: () => canonicalizeElement,
28
29
  canonicalizeTree: () => canonicalizeTree,
29
30
  claimHxOnAttribute: () => claimHxOnAttribute,
31
+ claimedAttrNames: () => claimedAttrNames,
30
32
  createExtension: () => createExtension,
31
33
  hasAnyVocab: () => hasAnyVocab,
32
34
  hasBodyExecutor: () => hasBodyExecutor,
33
35
  hasBodyTranslator: () => hasBodyTranslator,
34
36
  installAutoSweep: () => installAutoSweep,
35
37
  installResolverMode: () => installResolverMode,
38
+ isClaimed: () => isClaimed,
36
39
  isLangRegistered: () => isLangRegistered,
37
40
  isResolverMode: () => isResolverMode,
38
41
  langOf: () => langOf,
42
+ neutralizesOnClaim: () => neutralizesOnClaim,
39
43
  normLang: () => normLang,
40
44
  onBodyHooksChanged: () => onBodyHooksChanged,
41
45
  onVocabUpdate: () => onVocabUpdate,
42
46
  register: () => register,
43
47
  registerWith: () => registerWith,
48
+ removeClaimedCanonicalAttrs: () => removeClaimedCanonicalAttrs,
44
49
  resetBodyHooks: () => resetBodyHooks,
45
50
  resetRegistry: () => resetRegistry,
46
51
  setBodyExecutor: () => setBodyExecutor,
47
52
  setBodyTranslator: () => setBodyTranslator,
53
+ setNeutralizeOnClaim: () => setNeutralizeOnClaim,
48
54
  setResolverMode: () => setResolverMode,
55
+ setTriggerSpecSplitter: () => setTriggerSpecSplitter,
49
56
  translateTriggerValue: () => translateTriggerValue,
50
- vocabFor: () => vocabFor
57
+ vocabFor: () => vocabFor,
58
+ wasCreatedByAdapter: () => wasCreatedByAdapter
51
59
  });
52
60
  module.exports = __toCommonJS(src_exports);
53
61
 
@@ -110,10 +118,18 @@ function resetRegistry() {
110
118
  }
111
119
 
112
120
  // src/hx-on.ts
121
+ var hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
113
122
  var executor = null;
114
123
  var translator = null;
115
124
  var hookChangeListeners = /* @__PURE__ */ new Set();
116
125
  var claimed = /* @__PURE__ */ new WeakMap();
126
+ var neutralizeOnClaim = true;
127
+ function setNeutralizeOnClaim(enabled) {
128
+ neutralizeOnClaim = enabled;
129
+ }
130
+ function neutralizesOnClaim() {
131
+ return neutralizeOnClaim;
132
+ }
117
133
  function setBodyExecutor(fn) {
118
134
  executor = fn;
119
135
  for (const listener of hookChangeListeners) listener();
@@ -133,44 +149,76 @@ function onBodyHooksChanged(listener) {
133
149
  hookChangeListeners.delete(listener);
134
150
  };
135
151
  }
152
+ function isClaimed(elt) {
153
+ return (claimed.get(elt)?.size ?? 0) > 0;
154
+ }
155
+ function claimedAttrNames(elt) {
156
+ const names = /* @__PURE__ */ new Set();
157
+ const claims = claimed.get(elt);
158
+ if (claims) for (const c of claims.values()) names.add(c.attrName);
159
+ return names;
160
+ }
161
+ function removeClaimedCanonicalAttrs(elt) {
162
+ let n = 0;
163
+ for (const name of claimedAttrNames(elt)) {
164
+ if (name.startsWith("hx-on:") && elt.hasAttribute(name)) {
165
+ elt.removeAttribute(name);
166
+ n++;
167
+ }
168
+ }
169
+ return n;
170
+ }
136
171
  function eventNameForSuffix(rawSuffix, events) {
137
172
  if (rawSuffix.startsWith(":")) return `htmx${rawSuffix}`;
138
- return events[rawSuffix] ?? rawSuffix;
173
+ return hasOwn(events, rawSuffix) ? events[rawSuffix] : rawSuffix;
139
174
  }
140
- function claimHxOnAttribute(elt, attrName, lang, events) {
175
+ function claimHxOnAttribute(elt, attrName, lang, events, options = {}) {
141
176
  if (!executor) return false;
142
177
  const colon = attrName.indexOf(":");
143
178
  if (colon <= 0) return false;
144
179
  const eventName = eventNameForSuffix(attrName.slice(colon + 1), events);
145
- const already = claimed.get(elt);
146
- if (already?.has(eventName)) {
147
- if (attrName.startsWith("hx-on:") && elt.hasAttribute(attrName)) {
180
+ const canonical = attrName.startsWith("hx-on:");
181
+ const body = elt.getAttribute(attrName) ?? "";
182
+ let claims = claimed.get(elt);
183
+ const existing = claims?.get(eventName);
184
+ if (existing) {
185
+ if (existing.attrName === attrName) {
186
+ if (existing.body !== body) {
187
+ existing.body = body;
188
+ existing.translated = null;
189
+ }
190
+ if (canonical && neutralizeOnClaim && elt.hasAttribute(attrName)) {
191
+ elt.removeAttribute(attrName);
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+ if (canonical && (options.adapterCreated || neutralizeOnClaim) && elt.hasAttribute(attrName)) {
148
197
  elt.removeAttribute(attrName);
149
198
  return true;
150
199
  }
151
200
  return false;
152
201
  }
153
- const body = elt.getAttribute(attrName) ?? "";
154
- let translatedBody = null;
202
+ const claim = { attrName, lang, body, translated: null };
155
203
  elt.addEventListener(eventName, (evt) => {
156
204
  if (!executor) return;
157
- if (translatedBody === null) {
158
- translatedBody = lang !== "en" && translator ? translator(body, lang) : body;
205
+ if (claim.translated === null) {
206
+ claim.translated = claim.lang !== "en" && translator ? translator(claim.body, claim.lang) : claim.body;
159
207
  }
160
208
  try {
161
- executor(translatedBody, elt, evt);
209
+ executor(claim.translated, elt, evt);
162
210
  } catch (err) {
163
211
  if (typeof console !== "undefined") {
164
212
  console.error(`[htmx-i18n] hx-on body execution failed (${attrName} \u2192 ${eventName})`, err);
165
213
  }
166
214
  }
167
215
  });
168
- if (attrName.startsWith("hx-on:")) elt.removeAttribute(attrName);
169
- if (already) {
170
- already.add(eventName);
171
- } else {
172
- claimed.set(elt, /* @__PURE__ */ new Set([eventName]));
216
+ if (!claims) {
217
+ claims = /* @__PURE__ */ new Map();
218
+ claimed.set(elt, claims);
173
219
  }
220
+ claims.set(eventName, claim);
221
+ if (canonical && neutralizeOnClaim) elt.removeAttribute(attrName);
174
222
  return true;
175
223
  }
176
224
  function autoDetectBodyHooks(win) {
@@ -191,6 +239,7 @@ function resetBodyHooks() {
191
239
  translator = null;
192
240
  hookChangeListeners.clear();
193
241
  claimed = /* @__PURE__ */ new WeakMap();
242
+ neutralizeOnClaim = true;
194
243
  }
195
244
 
196
245
  // src/resolver.ts
@@ -242,19 +291,36 @@ function installResolverMode(htmx) {
242
291
  }
243
292
 
244
293
  // src/canonicalize.ts
294
+ var hasOwn2 = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
245
295
  var NS_RE = /^(?:hx|sse|ws)-/;
296
+ var TOP_LEVEL_COMMA_RE = /,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/;
297
+ var mirrorSplit = (value) => value.split(TOP_LEVEL_COMMA_RE);
298
+ var splitSpecs = mirrorSplit;
299
+ function setTriggerSpecSplitter(fn) {
300
+ splitSpecs = fn ?? mirrorSplit;
301
+ }
302
+ function isTriggerAttr(name) {
303
+ return name === "hx-trigger" || name.startsWith("hx-trigger:");
304
+ }
246
305
  function translateTriggerValue(value, events) {
247
- return value.split(",").map((spec) => {
248
- const trimmed = spec.trim();
249
- if (!trimmed) return trimmed;
250
- const parts = trimmed.split(/\s+/);
251
- const m = parts[0].match(/^([^[\s]+)(\[.*)?$/);
252
- if (m) {
253
- const translated = events[m[1]];
254
- if (translated) parts[0] = translated + (m[2] ?? "");
255
- }
256
- return parts.join(" ");
257
- }).join(", ");
306
+ return splitSpecs(value).map(
307
+ (spec) => spec.replace(
308
+ /^(\s*)([^[\s]+)/,
309
+ (whole, ws, evt) => hasOwn2(events, evt) ? ws + events[evt] : whole
310
+ )
311
+ ).join(",");
312
+ }
313
+ var created = /* @__PURE__ */ new WeakMap();
314
+ function markCreated(elt, name) {
315
+ let names = created.get(elt);
316
+ if (!names) {
317
+ names = /* @__PURE__ */ new Set();
318
+ created.set(elt, names);
319
+ }
320
+ names.add(name);
321
+ }
322
+ function wasCreatedByAdapter(elt, name) {
323
+ return created.get(elt)?.has(name) ?? false;
258
324
  }
259
325
  function canonicalizeElement(elt) {
260
326
  if (!elt.attributes || elt.attributes.length === 0) return false;
@@ -276,23 +342,43 @@ function canonicalizeElement(elt) {
276
342
  }
277
343
  const attrs = vocab?.attrs ?? {};
278
344
  const events = vocab?.events ?? {};
345
+ let hasEvents = false;
346
+ for (const _ in events) {
347
+ hasEvents = true;
348
+ break;
349
+ }
279
350
  let changed = false;
280
351
  for (const attr of Array.from(elt.attributes)) {
281
352
  const name = attr.name;
282
353
  if (!NS_RE.test(name)) continue;
283
354
  if (executorMode && name.startsWith("hx-on:")) {
284
- if (claimHxOnAttribute(elt, name, lang, events)) changed = true;
355
+ if (claimHxOnAttribute(elt, name, lang, events, {
356
+ adapterCreated: wasCreatedByAdapter(elt, name)
357
+ })) {
358
+ changed = true;
359
+ }
360
+ continue;
361
+ }
362
+ if (isTriggerAttr(name)) {
363
+ if (hasEvents) {
364
+ const translated = translateTriggerValue(attr.value, events);
365
+ if (translated !== attr.value) {
366
+ elt.setAttribute(name, translated);
367
+ changed = true;
368
+ }
369
+ }
285
370
  continue;
286
371
  }
287
- let canonical = attrs[name];
372
+ let canonical = hasOwn2(attrs, name) ? attrs[name] : void 0;
288
373
  let colonBase;
289
374
  if (!canonical) {
290
375
  const colon = name.indexOf(":");
291
376
  if (colon > 0) {
292
- colonBase = attrs[name.slice(0, colon)];
377
+ const base = name.slice(0, colon);
378
+ colonBase = hasOwn2(attrs, base) ? attrs[base] : void 0;
293
379
  if (colonBase) {
294
380
  const suffix = name.slice(colon + 1);
295
- canonical = `${colonBase}:${events[suffix] ?? suffix}`;
381
+ canonical = colonBase === "hx-on" ? `${colonBase}:${hasOwn2(events, suffix) ? events[suffix] : suffix}` : `${colonBase}:${suffix}`;
296
382
  }
297
383
  }
298
384
  }
@@ -303,18 +389,11 @@ function canonicalizeElement(elt) {
303
389
  }
304
390
  if (canonical === name || elt.hasAttribute(canonical)) continue;
305
391
  let value = attr.value;
306
- if (canonical === "hx-trigger") value = translateTriggerValue(value, events);
392
+ if (isTriggerAttr(canonical)) value = translateTriggerValue(value, events);
307
393
  elt.setAttribute(canonical, value);
394
+ markCreated(elt, canonical);
308
395
  changed = true;
309
396
  }
310
- const trigger = elt.getAttribute("hx-trigger");
311
- if (trigger !== null && Object.keys(events).length > 0) {
312
- const translated = translateTriggerValue(trigger, events);
313
- if (translated !== trigger) {
314
- elt.setAttribute("hx-trigger", translated);
315
- changed = true;
316
- }
317
- }
318
397
  return changed;
319
398
  }
320
399
  function canonicalizeTree(root) {
@@ -333,9 +412,24 @@ function canonicalizeTree(root) {
333
412
 
334
413
  // src/extension.ts
335
414
  var EXTENSION_NAME = "lokascript-i18n";
336
- function createExtension() {
415
+ function isHtmxBindableHxOn(name, prefixes, mc) {
416
+ for (const p of prefixes) {
417
+ if (name === p) return true;
418
+ if (name.startsWith(p) && name.charAt(p.length) === mc) return true;
419
+ }
420
+ return false;
421
+ }
422
+ function createExtension(htmx) {
423
+ const cfg = htmx?.config;
424
+ const prefix = cfg && "prefix" in cfg ? cfg.prefix ?? "" : "data-hx-";
425
+ const mc = cfg?.metaCharacter || ":";
426
+ const hxOnPrefixes = prefix ? ["hx-on", prefix + "on"] : ["hx-on"];
337
427
  return {
338
- // htmx v4 (verified on 4.0.0-beta5): fires on each process() root
428
+ init(internalAPI) {
429
+ const split = internalAPI?.HCON?.split;
430
+ if (typeof split === "function") setTriggerSpecSplitter(split.bind(internalAPI.HCON));
431
+ },
432
+ // htmx v4 (verified on 4.0.0): fires on each process() root
339
433
  // before element init and hx-on binding.
340
434
  htmx_before_process(elt) {
341
435
  canonicalizeTree(elt);
@@ -344,6 +438,36 @@ function createExtension() {
344
438
  htmx_before_process_node(elt) {
345
439
  canonicalizeTree(elt);
346
440
  },
441
+ // htmx v4 (verified on 4.0.0): fires per node carrying an
442
+ // hx-on-family attribute, cancelable — returning false makes htmx
443
+ // skip JS-binding that node entirely. This is executor mode's
444
+ // zero-mutation double-execution guard, decided from what the sweep
445
+ // RECORDED on the node (not from whether an executor is set now):
446
+ //
447
+ // - nothing claimed on this node → htmx proceeds as usual;
448
+ // - every hx-on attribute htmx would bind here is claimed → cancel,
449
+ // the authored attributes stay in the DOM;
450
+ // - the node also carries forms htmx must bind and the adapter never
451
+ // claims (the legacy composite `hx-on="event -> code"`, the
452
+ // `config.prefix` spelling, a custom meta character) → per-node
453
+ // cancellation would kill those too, so fall back to removing the
454
+ // claimed canonical attrs and let htmx bind the rest.
455
+ htmx_before_on_init(elt) {
456
+ const claimedNames = claimedAttrNames(elt);
457
+ if (claimedNames.size === 0) return void 0;
458
+ let unclaimedBindable = false;
459
+ for (const attr of Array.from(elt.attributes)) {
460
+ const name = attr.name;
461
+ if (claimedNames.has(name)) continue;
462
+ if (isHtmxBindableHxOn(name, hxOnPrefixes, mc)) {
463
+ unclaimedBindable = true;
464
+ break;
465
+ }
466
+ }
467
+ if (!unclaimedBindable) return false;
468
+ removeClaimedCanonicalAttrs(elt);
469
+ return void 0;
470
+ },
347
471
  // htmx v1/v2 fallback: single event dispatcher.
348
472
  onEvent(name, evt) {
349
473
  if (name !== "htmx:beforeProcessNode") return;
@@ -355,13 +479,23 @@ function createExtension() {
355
479
  }
356
480
  function registerWith(htmx) {
357
481
  if (!htmx) return null;
358
- const ext = createExtension();
482
+ const ext = createExtension(htmx);
359
483
  if (typeof htmx.registerExtension === "function") {
360
- htmx.registerExtension(EXTENSION_NAME, ext);
484
+ if (htmx.registerExtension(EXTENSION_NAME, ext) === false) {
485
+ if (typeof console !== "undefined") {
486
+ console.warn(
487
+ `[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.`
488
+ );
489
+ }
490
+ setNeutralizeOnClaim(true);
491
+ return null;
492
+ }
493
+ setNeutralizeOnClaim(false);
361
494
  return "v4";
362
495
  }
363
496
  if (typeof htmx.defineExtension === "function") {
364
497
  htmx.defineExtension(EXTENSION_NAME, ext);
498
+ setNeutralizeOnClaim(true);
365
499
  return "v2";
366
500
  }
367
501
  return null;
@@ -370,22 +504,33 @@ function installAutoSweep(doc = document) {
370
504
  const sweep = () => {
371
505
  canonicalizeTree(doc.body ?? doc.documentElement);
372
506
  };
373
- let removeDomListener = null;
374
- if (doc.readyState === "loading") {
375
- const onReady = () => sweep();
376
- doc.addEventListener("DOMContentLoaded", onReady, { once: true });
377
- removeDomListener = () => doc.removeEventListener("DOMContentLoaded", onReady);
378
- } else {
507
+ let ready = doc.readyState === "complete";
508
+ let removeReadyListeners = null;
509
+ if (ready) {
379
510
  sweep();
511
+ } else {
512
+ const onReady = () => {
513
+ if (ready) return;
514
+ ready = true;
515
+ removeReadyListeners?.();
516
+ sweep();
517
+ };
518
+ const win = doc.defaultView;
519
+ doc.addEventListener("DOMContentLoaded", onReady);
520
+ win?.addEventListener("load", onReady);
521
+ removeReadyListeners = () => {
522
+ doc.removeEventListener("DOMContentLoaded", onReady);
523
+ win?.removeEventListener("load", onReady);
524
+ };
380
525
  }
381
526
  const unsubscribeVocab = onVocabUpdate(() => {
382
- if (doc.readyState !== "loading") sweep();
527
+ if (ready) sweep();
383
528
  });
384
529
  const unsubscribeBodyHooks = onBodyHooksChanged(() => {
385
- if (doc.readyState !== "loading") sweep();
530
+ if (ready) sweep();
386
531
  });
387
532
  return () => {
388
- removeDomListener?.();
533
+ removeReadyListeners?.();
389
534
  unsubscribeVocab();
390
535
  unsubscribeBodyHooks();
391
536
  };
@@ -393,32 +538,40 @@ function installAutoSweep(doc = document) {
393
538
  // Annotate the CommonJS export names for ESM import in node:
394
539
  0 && (module.exports = {
395
540
  EXTENSION_NAME,
541
+ TOP_LEVEL_COMMA_RE,
396
542
  additionalAttributeSelectors,
397
543
  attributeResolver,
398
544
  autoDetectBodyHooks,
399
545
  canonicalizeElement,
400
546
  canonicalizeTree,
401
547
  claimHxOnAttribute,
548
+ claimedAttrNames,
402
549
  createExtension,
403
550
  hasAnyVocab,
404
551
  hasBodyExecutor,
405
552
  hasBodyTranslator,
406
553
  installAutoSweep,
407
554
  installResolverMode,
555
+ isClaimed,
408
556
  isLangRegistered,
409
557
  isResolverMode,
410
558
  langOf,
559
+ neutralizesOnClaim,
411
560
  normLang,
412
561
  onBodyHooksChanged,
413
562
  onVocabUpdate,
414
563
  register,
415
564
  registerWith,
565
+ removeClaimedCanonicalAttrs,
416
566
  resetBodyHooks,
417
567
  resetRegistry,
418
568
  setBodyExecutor,
419
569
  setBodyTranslator,
570
+ setNeutralizeOnClaim,
420
571
  setResolverMode,
572
+ setTriggerSpecSplitter,
421
573
  translateTriggerValue,
422
- vocabFor
574
+ vocabFor,
575
+ wasCreatedByAdapter
423
576
  });
424
577
  //# sourceMappingURL=index.cjs.map