@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.
@@ -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
+ }
@@ -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
+ }