@lokascript/htmx-adapter 2.8.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/README.md +187 -0
- package/dist/htmx-i18n.global.js +2 -0
- package/dist/htmx-i18n.global.js.map +1 -0
- package/dist/index.cjs +424 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +305 -0
- package/dist/index.d.ts +305 -0
- package/dist/index.js +370 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/src/browser.ts +108 -0
- package/src/canonicalize.ts +186 -0
- package/src/extension.ts +122 -0
- package/src/hx-on.ts +197 -0
- package/src/index.ts +57 -0
- package/src/lang-resolver.ts +34 -0
- package/src/registry.ts +112 -0
- package/src/resolver.ts +104 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Localized → canonical attribute canonicalization for upstream htmx.
|
|
3
|
+
*
|
|
4
|
+
* htmx v4 has no hook to override how core resolves an attribute name —
|
|
5
|
+
* it reads `hx-get` literally. Until an upstream resolver seam exists
|
|
6
|
+
* (see docs/UPSTREAM_HOOK_PROPOSAL.md), this module makes localized
|
|
7
|
+
* authoring work by copying each localized attribute to its canonical
|
|
8
|
+
* name on the same element *before* htmx processes the node:
|
|
9
|
+
*
|
|
10
|
+
* <button lang="es" hx-obtener="/api"> → + hx-get="/api"
|
|
11
|
+
*
|
|
12
|
+
* Design rules (mirroring the loka-js invariants where the mechanism
|
|
13
|
+
* allows):
|
|
14
|
+
*
|
|
15
|
+
* - **The authored attribute is never removed or rewritten** — devtools
|
|
16
|
+
* keeps showing what the author wrote. The one exception is an
|
|
17
|
+
* author-written canonical `hx-trigger` whose *value* uses localized
|
|
18
|
+
* event names (`hx-trigger="clic"`): there is no separate canonical
|
|
19
|
+
* target to write to, so the value is translated in place (idempotent —
|
|
20
|
+
* the maps are localized → canonical, so a second pass is a no-op).
|
|
21
|
+
* - **An existing canonical attribute always wins.** If the element
|
|
22
|
+
* already has `hx-get`, a localized `hx-obtener` never overwrites it.
|
|
23
|
+
* - **No vocab, no work.** With no languages registered every function
|
|
24
|
+
* here is a cheap no-op, so stock htmx pages pay ~nothing.
|
|
25
|
+
*
|
|
26
|
+
* Only attributes in the `hx-` / `sse-` / `ws-` namespaces are ever
|
|
27
|
+
* considered — the brand prefix is preserved across languages (Phase 8
|
|
28
|
+
* convention: Spanish writes `hx-obtener`, not `xx-obtener`), so the
|
|
29
|
+
* prefix doubles as the discovery anchor.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { langOf } from './lang-resolver.js';
|
|
33
|
+
import { hasAnyVocab, vocabFor, warnMissingLangOnce } from './registry.js';
|
|
34
|
+
import { claimHxOnAttribute, hasBodyExecutor } from './hx-on.js';
|
|
35
|
+
import { isResolverMode } from './resolver.js';
|
|
36
|
+
|
|
37
|
+
/** Attribute namespaces the adapter touches. */
|
|
38
|
+
const NS_RE = /^(?:hx|sse|ws)-/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Translate localized event names inside an `hx-trigger` value.
|
|
42
|
+
*
|
|
43
|
+
* hx-trigger grammar: comma-separated specs, each `eventName[filter]
|
|
44
|
+
* modifier…`. Only the leading event token of each spec is translated
|
|
45
|
+
* (preserving an attached `[...]` filter); modifiers like `delay:500ms`,
|
|
46
|
+
* `from:body`, `once` are language-invariant and left alone. Unknown
|
|
47
|
+
* tokens pass through untouched, which also makes translation idempotent
|
|
48
|
+
* (the maps are localized → canonical only).
|
|
49
|
+
*/
|
|
50
|
+
export function translateTriggerValue(value: string, events: Record<string, string>): string {
|
|
51
|
+
return value
|
|
52
|
+
.split(',')
|
|
53
|
+
.map(spec => {
|
|
54
|
+
const trimmed = spec.trim();
|
|
55
|
+
if (!trimmed) return trimmed;
|
|
56
|
+
const parts = trimmed.split(/\s+/);
|
|
57
|
+
const m = parts[0].match(/^([^[\s]+)(\[.*)?$/);
|
|
58
|
+
if (m) {
|
|
59
|
+
const translated = events[m[1]];
|
|
60
|
+
if (translated) parts[0] = translated + (m[2] ?? '');
|
|
61
|
+
}
|
|
62
|
+
return parts.join(' ');
|
|
63
|
+
})
|
|
64
|
+
.join(', ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Canonicalize one element's localized htmx attributes in place.
|
|
69
|
+
* Returns true if any attribute was added or updated.
|
|
70
|
+
*/
|
|
71
|
+
export function canonicalizeElement(elt: Element): boolean {
|
|
72
|
+
if (!elt.attributes || elt.attributes.length === 0) return false;
|
|
73
|
+
|
|
74
|
+
// Cheap prefilter before any lang resolution: bail unless some
|
|
75
|
+
// attribute is in our namespaces.
|
|
76
|
+
let hasNsAttr = false;
|
|
77
|
+
for (const attr of Array.from(elt.attributes)) {
|
|
78
|
+
if (NS_RE.test(attr.name)) {
|
|
79
|
+
hasNsAttr = true;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!hasNsAttr) return false;
|
|
84
|
+
|
|
85
|
+
const executorMode = hasBodyExecutor();
|
|
86
|
+
const lang = langOf(elt);
|
|
87
|
+
if (lang === 'en' && !executorMode) return false;
|
|
88
|
+
|
|
89
|
+
const vocab = vocabFor(lang);
|
|
90
|
+
if (lang !== 'en' && !vocab) {
|
|
91
|
+
warnMissingLangOnce(lang);
|
|
92
|
+
// Executor mode still claims canonical-named hx-on:* attrs below —
|
|
93
|
+
// body semantics don't depend on vocab being loaded.
|
|
94
|
+
if (!executorMode) return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const attrs = vocab?.attrs ?? {};
|
|
98
|
+
const events = vocab?.events ?? {};
|
|
99
|
+
let changed = false;
|
|
100
|
+
|
|
101
|
+
// Snapshot — we mutate the attribute list while iterating.
|
|
102
|
+
for (const attr of Array.from(elt.attributes)) {
|
|
103
|
+
const name = attr.name;
|
|
104
|
+
if (!NS_RE.test(name)) continue;
|
|
105
|
+
|
|
106
|
+
// Executor mode owns the hx-on family outright: canonical-named
|
|
107
|
+
// attrs are claimed (listener installed, attr removed so htmx never
|
|
108
|
+
// JS-evals the hyperscript body)…
|
|
109
|
+
if (executorMode && name.startsWith('hx-on:')) {
|
|
110
|
+
if (claimHxOnAttribute(elt, name, lang, events)) changed = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Exact match: hx-obtener → hx-get.
|
|
115
|
+
let canonical = attrs[name];
|
|
116
|
+
|
|
117
|
+
// Colon family (hx-on:*): hx-en:clic → hx-on:click. The base is
|
|
118
|
+
// looked up in attrs, the event suffix in events.
|
|
119
|
+
let colonBase: string | undefined;
|
|
120
|
+
if (!canonical) {
|
|
121
|
+
const colon = name.indexOf(':');
|
|
122
|
+
if (colon > 0) {
|
|
123
|
+
colonBase = attrs[name.slice(0, colon)];
|
|
124
|
+
if (colonBase) {
|
|
125
|
+
const suffix = name.slice(colon + 1);
|
|
126
|
+
canonical = `${colonBase}:${events[suffix] ?? suffix}`;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!canonical) continue;
|
|
132
|
+
|
|
133
|
+
// …and localized-named hx-on attrs are claimed in place: no
|
|
134
|
+
// canonical sibling is created, the authored attr stays verbatim
|
|
135
|
+
// (htmx never recognized it).
|
|
136
|
+
if (executorMode && colonBase === 'hx-on') {
|
|
137
|
+
if (claimHxOnAttribute(elt, name, lang, events)) changed = true;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (canonical === name || elt.hasAttribute(canonical)) continue;
|
|
142
|
+
|
|
143
|
+
let value = attr.value;
|
|
144
|
+
if (canonical === 'hx-trigger') value = translateTriggerValue(value, events);
|
|
145
|
+
elt.setAttribute(canonical, value);
|
|
146
|
+
changed = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Author-written canonical hx-trigger with localized event values
|
|
150
|
+
// (`hx-trigger="clic"`). Translated in place — the only mutation of an
|
|
151
|
+
// authored attribute, because there is no separate canonical target.
|
|
152
|
+
const trigger = elt.getAttribute('hx-trigger');
|
|
153
|
+
if (trigger !== null && Object.keys(events).length > 0) {
|
|
154
|
+
const translated = translateTriggerValue(trigger, events);
|
|
155
|
+
if (translated !== trigger) {
|
|
156
|
+
elt.setAttribute('hx-trigger', translated);
|
|
157
|
+
changed = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return changed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Canonicalize an element and all its descendants. Returns the number of
|
|
166
|
+
* elements that changed. This is what the htmx extension hook and the
|
|
167
|
+
* initial document sweep call — htmx processes subtrees, so we mirror
|
|
168
|
+
* that granularity.
|
|
169
|
+
*/
|
|
170
|
+
export function canonicalizeTree(root: Element | Document | DocumentFragment | null): number {
|
|
171
|
+
if (!root) return 0;
|
|
172
|
+
// Resolver mode (patched htmx answers reads directly) stands the
|
|
173
|
+
// canonicalization shim down entirely — zero DOM mutation.
|
|
174
|
+
if (isResolverMode()) return 0;
|
|
175
|
+
// Executor mode must sweep even with no vocab loaded (English
|
|
176
|
+
// hyperscript bodies need claiming too).
|
|
177
|
+
if (!hasAnyVocab() && !hasBodyExecutor()) return 0;
|
|
178
|
+
let count = 0;
|
|
179
|
+
if (root instanceof Element && canonicalizeElement(root)) count++;
|
|
180
|
+
if (typeof (root as Element).querySelectorAll === 'function') {
|
|
181
|
+
for (const el of Array.from((root as Element).querySelectorAll('*'))) {
|
|
182
|
+
if (canonicalizeElement(el)) count++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return count;
|
|
186
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* htmx extension + auto-sweep wiring.
|
|
3
|
+
*
|
|
4
|
+
* Primary target is **htmx v4**, whose extensions register via
|
|
5
|
+
* `htmx.registerExtension(name, ext)` and hook lifecycle events through
|
|
6
|
+
* underscore-named methods (event name with `:` → `_`), each receiving
|
|
7
|
+
* `(elt, detail)`. Verified against htmx 4.0.0-beta5 (see
|
|
8
|
+
* test/browser/vendor/README.md): `process(root)` fires
|
|
9
|
+
* `htmx:before:process` on the processed root — `document.body`
|
|
10
|
+
* initially, each swapped-in subtree afterwards — BEFORE any element
|
|
11
|
+
* init or hx-on binding, so canonicalizing in `htmx_before_process`
|
|
12
|
+
* covers everything htmx will read. `htmx_before_process_node` is kept
|
|
13
|
+
* as a defensive alias for other v4 prereleases that used per-node
|
|
14
|
+
* naming; an unmatched key is inert.
|
|
15
|
+
*
|
|
16
|
+
* A v2 fallback (`htmx.defineExtension` + `onEvent('htmx:beforeProcessNode')`)
|
|
17
|
+
* is included because the localized attribute names are version-agnostic
|
|
18
|
+
* data — but v2 support is best-effort, not a tested target.
|
|
19
|
+
*
|
|
20
|
+
* The extension hook alone is not enough for the *initial* page: script
|
|
21
|
+
* order decides whether our sweep beats htmx's own DOMContentLoaded scan.
|
|
22
|
+
* `installAutoSweep()` handles that — load this adapter (and vocab
|
|
23
|
+
* modules) BEFORE the htmx <script> tag, mirroring loka-js's
|
|
24
|
+
* "orchestrator before libraries" rule, and the sweep listener registers
|
|
25
|
+
* ahead of htmx's.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { canonicalizeTree } from './canonicalize.js';
|
|
29
|
+
import { onVocabUpdate } from './registry.js';
|
|
30
|
+
import { onBodyHooksChanged } from './hx-on.js';
|
|
31
|
+
|
|
32
|
+
export const EXTENSION_NAME = 'lokascript-i18n';
|
|
33
|
+
|
|
34
|
+
/** Minimal shape of the htmx global we interact with. */
|
|
35
|
+
export interface HtmxLike {
|
|
36
|
+
/** htmx v4 registration entry point. */
|
|
37
|
+
registerExtension?(name: string, extension: object): void;
|
|
38
|
+
/** htmx v1/v2 registration entry point. */
|
|
39
|
+
defineExtension?(name: string, extension: object): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the extension object. v4 hooks and the v2 `onEvent` callback are
|
|
44
|
+
* both present — each API only reads the members it knows about.
|
|
45
|
+
*/
|
|
46
|
+
export function createExtension(): object {
|
|
47
|
+
return {
|
|
48
|
+
// htmx v4 (verified on 4.0.0-beta5): fires on each process() root
|
|
49
|
+
// before element init and hx-on binding.
|
|
50
|
+
htmx_before_process(elt: Element): void {
|
|
51
|
+
canonicalizeTree(elt);
|
|
52
|
+
},
|
|
53
|
+
// Defensive alias for v4 prereleases with per-node hook naming.
|
|
54
|
+
htmx_before_process_node(elt: Element): void {
|
|
55
|
+
canonicalizeTree(elt);
|
|
56
|
+
},
|
|
57
|
+
// htmx v1/v2 fallback: single event dispatcher.
|
|
58
|
+
onEvent(name: string, evt: CustomEvent & { target?: EventTarget | null }): void {
|
|
59
|
+
if (name !== 'htmx:beforeProcessNode') return;
|
|
60
|
+
const detail = evt?.detail as { elt?: Element } | undefined;
|
|
61
|
+
const elt = detail?.elt ?? (evt?.target instanceof Element ? evt.target : null);
|
|
62
|
+
if (elt) canonicalizeTree(elt);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register the extension with an htmx global. Returns which API accepted
|
|
69
|
+
* it (`'v4'` / `'v2'`) or `null` if the object exposes neither.
|
|
70
|
+
*/
|
|
71
|
+
export function registerWith(htmx: HtmxLike | undefined | null): 'v4' | 'v2' | null {
|
|
72
|
+
if (!htmx) return null;
|
|
73
|
+
const ext = createExtension();
|
|
74
|
+
if (typeof htmx.registerExtension === 'function') {
|
|
75
|
+
htmx.registerExtension(EXTENSION_NAME, ext);
|
|
76
|
+
return 'v4';
|
|
77
|
+
}
|
|
78
|
+
if (typeof htmx.defineExtension === 'function') {
|
|
79
|
+
htmx.defineExtension(EXTENSION_NAME, ext);
|
|
80
|
+
return 'v2';
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
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).
|
|
89
|
+
*
|
|
90
|
+
* Returns a cleanup function (mainly for tests).
|
|
91
|
+
*/
|
|
92
|
+
export function installAutoSweep(doc: Document = document): () => void {
|
|
93
|
+
const sweep = (): void => {
|
|
94
|
+
canonicalizeTree(doc.body ?? doc.documentElement);
|
|
95
|
+
};
|
|
96
|
+
|
|
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 {
|
|
103
|
+
sweep();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const unsubscribeVocab = onVocabUpdate(() => {
|
|
107
|
+
if (doc.readyState !== 'loading') sweep();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// A body executor configured after the initial sweep flips the hx-on
|
|
111
|
+
// family into executor mode — re-sweep so already-canonicalized
|
|
112
|
+
// hx-on:* attrs get claimed (listener installed, attr removed).
|
|
113
|
+
const unsubscribeBodyHooks = onBodyHooksChanged(() => {
|
|
114
|
+
if (doc.readyState !== 'loading') sweep();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
removeDomListener?.();
|
|
119
|
+
unsubscribeVocab();
|
|
120
|
+
unsubscribeBodyHooks();
|
|
121
|
+
};
|
|
122
|
+
}
|
package/src/hx-on.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hx-on body support — opt-in "executor mode".
|
|
3
|
+
*
|
|
4
|
+
* Upstream htmx evals `hx-on:*` bodies as JavaScript. In the hyperfixi
|
|
5
|
+
* ecosystem the convention is hyperscript bodies (`hx-on:click="toggle
|
|
6
|
+
* .active on me"`), which htmx's JS eval cannot run — and localized
|
|
7
|
+
* bodies (`hx-en:clic="alternar .active"`) doubly so. This module lets a
|
|
8
|
+
* page opt in to hyperscript-body semantics by configuring an executor:
|
|
9
|
+
*
|
|
10
|
+
* setBodyExecutor((code, elt, evt) => _hyperscript.evaluate(code, { me: elt, event: evt }))
|
|
11
|
+
* setBodyTranslator((body, lang) => HyperscriptI18n.preprocess(body, lang))
|
|
12
|
+
*
|
|
13
|
+
* With an executor set, the adapter CLAIMS every hx-on-family attribute
|
|
14
|
+
* (localized-named or canonical-named — all bodies are treated as
|
|
15
|
+
* hyperscript; mixed JS/hyperscript pages have no reliable detection):
|
|
16
|
+
* it installs a real event listener that runs the (lazily translated)
|
|
17
|
+
* body through the executor, and keeps htmx away from it:
|
|
18
|
+
*
|
|
19
|
+
* - Localized-named attrs (`hx-en:clic`) stay verbatim in the DOM — htmx
|
|
20
|
+
* never recognized them anyway — and NO canonical `hx-on:*` sibling is
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* With no executor set (the default), none of this runs and bodies keep
|
|
29
|
+
* upstream JS semantics — the behavior-preservation invariant.
|
|
30
|
+
*
|
|
31
|
+
* Translation is lazy (first event fire, memoized) so a translator that
|
|
32
|
+
* loads after the initial sweep still applies. Both auto-detection
|
|
33
|
+
* (`autoDetectBodyHooks`) and manual configuration are supported; the
|
|
34
|
+
* executor is also re-read at fire time so replacing it takes effect on
|
|
35
|
+
* live listeners.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type BodyExecutor = (code: string, elt: Element, evt: Event) => unknown;
|
|
39
|
+
export type BodyTranslator = (body: string, lang: string) => string;
|
|
40
|
+
|
|
41
|
+
let executor: BodyExecutor | null = null;
|
|
42
|
+
let translator: BodyTranslator | null = null;
|
|
43
|
+
|
|
44
|
+
/** Listeners notified when the executor is set/cleared (drives re-sweeps). */
|
|
45
|
+
const hookChangeListeners = new Set<() => void>();
|
|
46
|
+
|
|
47
|
+
/**
|
|
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.
|
|
55
|
+
*/
|
|
56
|
+
let claimed = new WeakMap<Element, Set<string>>();
|
|
57
|
+
|
|
58
|
+
/** Configure the body executor. Setting/clearing it notifies subscribers. */
|
|
59
|
+
export function setBodyExecutor(fn: BodyExecutor | null): void {
|
|
60
|
+
executor = fn;
|
|
61
|
+
for (const listener of hookChangeListeners) listener();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Configure the body translator (localized body → English hyperscript). */
|
|
65
|
+
export function setBodyTranslator(fn: BodyTranslator | null): void {
|
|
66
|
+
translator = fn;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function hasBodyExecutor(): boolean {
|
|
70
|
+
return executor !== null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function hasBodyTranslator(): boolean {
|
|
74
|
+
return translator !== null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Subscribe to executor changes. Returns an unsubscribe fn. */
|
|
78
|
+
export function onBodyHooksChanged(listener: () => void): () => void {
|
|
79
|
+
hookChangeListeners.add(listener);
|
|
80
|
+
return () => {
|
|
81
|
+
hookChangeListeners.delete(listener);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the DOM event name for an hx-on attribute suffix.
|
|
87
|
+
* `hx-on::after-swap` shorthand (leading `:`) means the `htmx:` namespace;
|
|
88
|
+
* plain suffixes translate through the vocab events map.
|
|
89
|
+
*/
|
|
90
|
+
function eventNameForSuffix(rawSuffix: string, events: Record<string, string>): string {
|
|
91
|
+
if (rawSuffix.startsWith(':')) return `htmx${rawSuffix}`;
|
|
92
|
+
return events[rawSuffix] ?? rawSuffix;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
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).
|
|
99
|
+
*
|
|
100
|
+
* `lang` is the element's language at claim time — used for lazy body
|
|
101
|
+
* translation. `events` is the language's event-name map (for the
|
|
102
|
+
* listener's event name; the shorthand/unknown cases pass through).
|
|
103
|
+
*/
|
|
104
|
+
export function claimHxOnAttribute(
|
|
105
|
+
elt: Element,
|
|
106
|
+
attrName: string,
|
|
107
|
+
lang: string,
|
|
108
|
+
events: Record<string, string>
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!executor) return false;
|
|
111
|
+
|
|
112
|
+
const colon = attrName.indexOf(':');
|
|
113
|
+
if (colon <= 0) return false; // colon-form only; legacy composite hx-on="…" unsupported
|
|
114
|
+
|
|
115
|
+
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)) {
|
|
122
|
+
elt.removeAttribute(attrName);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
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;
|
|
133
|
+
elt.addEventListener(eventName, evt => {
|
|
134
|
+
if (!executor) return; // executor cleared after claim — go quiet
|
|
135
|
+
if (translatedBody === null) {
|
|
136
|
+
translatedBody = lang !== 'en' && translator ? translator(body, lang) : body;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
executor(translatedBody, elt, evt);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (typeof console !== 'undefined') {
|
|
142
|
+
console.error(`[htmx-i18n] hx-on body execution failed (${attrName} → ${eventName})`, err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
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]));
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Auto-detect body hooks from page globals — called by the browser entry
|
|
162
|
+
* at load and again at DOMContentLoaded. Never overwrites hooks that
|
|
163
|
+
* were set explicitly.
|
|
164
|
+
*
|
|
165
|
+
* - `window._hyperscript` → executor (original _hyperscript; pairs with
|
|
166
|
+
* `@lokascript/hyperscript-adapter` for multilingual `_=` too)
|
|
167
|
+
* - `window.HyperscriptI18n.preprocess` → translator (the
|
|
168
|
+
* hyperscript-adapter browser bundles expose exactly this)
|
|
169
|
+
*/
|
|
170
|
+
export function autoDetectBodyHooks(win: object): void {
|
|
171
|
+
const w = win as {
|
|
172
|
+
_hyperscript?: ((code: string) => unknown) & {
|
|
173
|
+
evaluate?: (code: string, ctx?: object) => unknown;
|
|
174
|
+
};
|
|
175
|
+
HyperscriptI18n?: { preprocess?: (src: string, lang: string) => string };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (!executor && typeof w._hyperscript === 'function') {
|
|
179
|
+
const hs = w._hyperscript;
|
|
180
|
+
setBodyExecutor((code, elt, evt) =>
|
|
181
|
+
typeof hs.evaluate === 'function' ? hs.evaluate(code, { me: elt, event: evt }) : hs(code)
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!translator && typeof w.HyperscriptI18n?.preprocess === 'function') {
|
|
186
|
+
const preprocess = w.HyperscriptI18n.preprocess;
|
|
187
|
+
setBodyTranslator((body, lang) => preprocess(body, lang));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reset all body-hook state. Mainly for tests. */
|
|
192
|
+
export function resetBodyHooks(): void {
|
|
193
|
+
executor = null;
|
|
194
|
+
translator = null;
|
|
195
|
+
hookChangeListeners.clear();
|
|
196
|
+
claimed = new WeakMap();
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lokascript/htmx-adapter — multilingual adapter for upstream htmx v4.
|
|
3
|
+
*
|
|
4
|
+
* Lets authors write `hx-*` / `sse-*` / `ws-*` attributes in 24 languages
|
|
5
|
+
* against the stock htmx library. Localized names are canonicalized on
|
|
6
|
+
* the element before htmx processes it (see canonicalize.ts); vocab data
|
|
7
|
+
* is the same generated `packages/core/vocab/htmx/{lang}.js` modules the
|
|
8
|
+
* embedded hyperfixi htmx-compat layer uses.
|
|
9
|
+
*
|
|
10
|
+
* Programmatic use:
|
|
11
|
+
*
|
|
12
|
+
* import { register, registerWith, installAutoSweep } from '@lokascript/htmx-adapter';
|
|
13
|
+
* register('es', { hyperfixi: { attrs: { 'hx-obtener': 'hx-get' }, events: { clic: 'click' } } });
|
|
14
|
+
* registerWith(window.htmx); // v4 extension (v2 fallback)
|
|
15
|
+
* installAutoSweep(); // initial-page sweep
|
|
16
|
+
*
|
|
17
|
+
* Browser IIFE (`./browser` export) does all of the above automatically.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { langOf, normLang } from './lang-resolver.js';
|
|
21
|
+
export {
|
|
22
|
+
register,
|
|
23
|
+
vocabFor,
|
|
24
|
+
isLangRegistered,
|
|
25
|
+
hasAnyVocab,
|
|
26
|
+
onVocabUpdate,
|
|
27
|
+
resetRegistry,
|
|
28
|
+
type HtmxVocab,
|
|
29
|
+
type VocabPayload,
|
|
30
|
+
} from './registry.js';
|
|
31
|
+
export { canonicalizeElement, canonicalizeTree, translateTriggerValue } from './canonicalize.js';
|
|
32
|
+
export {
|
|
33
|
+
EXTENSION_NAME,
|
|
34
|
+
createExtension,
|
|
35
|
+
registerWith,
|
|
36
|
+
installAutoSweep,
|
|
37
|
+
type HtmxLike,
|
|
38
|
+
} from './extension.js';
|
|
39
|
+
export {
|
|
40
|
+
attributeResolver,
|
|
41
|
+
additionalAttributeSelectors,
|
|
42
|
+
installResolverMode,
|
|
43
|
+
isResolverMode,
|
|
44
|
+
setResolverMode,
|
|
45
|
+
} from './resolver.js';
|
|
46
|
+
export {
|
|
47
|
+
setBodyExecutor,
|
|
48
|
+
setBodyTranslator,
|
|
49
|
+
hasBodyExecutor,
|
|
50
|
+
hasBodyTranslator,
|
|
51
|
+
onBodyHooksChanged,
|
|
52
|
+
claimHxOnAttribute,
|
|
53
|
+
autoDetectBodyHooks,
|
|
54
|
+
resetBodyHooks,
|
|
55
|
+
type BodyExecutor,
|
|
56
|
+
type BodyTranslator,
|
|
57
|
+
} from './hx-on.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-element language resolution for localized htmx attribute names.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `packages/core/src/htmx/lang-resolver.ts` (which in turn mirrors
|
|
5
|
+
* loka-js's `lang-resolver.js`) so a page migrating between hyperfixi's
|
|
6
|
+
* embedded htmx-compat layer and this upstream-htmx adapter resolves
|
|
7
|
+
* languages identically. Resolution order:
|
|
8
|
+
*
|
|
9
|
+
* 1. `data-hyperfixi-lang` attribute on the element itself
|
|
10
|
+
* 2. `data-hyperfixi-lang` on any ancestor
|
|
11
|
+
* 3. `lang` attribute on any ancestor (HTML standard)
|
|
12
|
+
* 4. `'en'` fallback
|
|
13
|
+
*
|
|
14
|
+
* Results are normalized to the part before the first `-`/`_` (`es-MX` →
|
|
15
|
+
* `es`) and lowercased so callers can index vocab maps by 2-letter code
|
|
16
|
+
* without caring about regional variants.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Normalize a language tag — `es-MX` / `ES_mx` → `es`. */
|
|
20
|
+
export function normLang(s: string | null | undefined): string {
|
|
21
|
+
if (!s) return 'en';
|
|
22
|
+
return s.split(/[-_]/)[0].toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve the language code for an element via ancestor walk. */
|
|
26
|
+
export function langOf(elt: Element): string {
|
|
27
|
+
const own = elt.getAttribute?.('data-hyperfixi-lang');
|
|
28
|
+
if (own) return normLang(own);
|
|
29
|
+
const dx = elt.closest?.('[data-hyperfixi-lang]');
|
|
30
|
+
if (dx) return normLang(dx.getAttribute('data-hyperfixi-lang'));
|
|
31
|
+
const la = elt.closest?.('[lang]');
|
|
32
|
+
if (la) return normLang(la.getAttribute('lang'));
|
|
33
|
+
return 'en';
|
|
34
|
+
}
|