@lokascript/htmx-adapter
v4.0.1
Published
Multilingual adapter for upstream htmx v4 — author hx-* attributes in 24 languages
Maintainers
Readme
@lokascript/htmx-adapter
Multilingual adapter for upstream htmx v4 — author hx-* / sse-* / ws-*
attributes in 24 languages against the stock htmx library.
<script src="htmx-i18n.global.js"></script>
<!-- this adapter (2.3 KB gz) -->
<script src="vocab/es.js"></script>
<!-- vocab module(s) -->
<script src="htmx.js"></script>
<!-- upstream htmx v4 -->
<section lang="es">
<button hx-obtener="/api/usuarios" hx-objetivo="#out" hx-disparar="clic">Cargar</button>
</section>The button issues a real htmx GET /api/usuarios targeting #out on click.
The localized attributes stay verbatim in the DOM.
How it relates to the rest of the ecosystem
This package is the htmx analog of
@lokascript/hyperscript-adapter (which adapts
upstream _hyperscript), built on the hook/vocab pattern from
loka-js (which adapts the fixiproject
family). It replaces hyperfixi's embedded htmx-compat layer (the
hyperfixi-hx*.js bundles of @hyperfixi/core 3.x, retired in 4.0), which
reimplemented htmx attributes on hyperfixi's own runtime. This adapter drives
the real htmx library.
The vocabulary is the generated modules in vocab/, one
self-registering script per language, derived from @lokascript/semantic
profiles + @lokascript/i18n dictionaries (so click is clic here as in
hyperscript) plus a hand-authored table for the attributes that are not
hyperscript keywords. They ship in this package: from a CDN,
https://unpkg.com/@lokascript/htmx-adapter/vocab/es.js. (Until 4.0 they
shipped in @hyperfixi/core, as vocab/htmx/{lang}.js.)
Mechanism
htmx v4 has no hook to override attribute-name resolution — core reads hx-get
literally. We maintain a proposed upstream seam plus a working ~30-line
reference patch against 4.0.0 and a ready-to-file Discussion draft
(docs/UPSTREAM_HOOK_PROPOSAL.md); against a
patched build, installResolverMode(htmx) localizes with zero DOM
mutation (proven in the e2e suite). Until the seam exists upstream, the
adapter canonicalizes: before htmx processes a node, each localized
attribute is copied to its canonical name on the same element
(hx-obtener="/x" gains a sibling hx-get="/x"). Two coverage paths:
- Initial page — a document sweep at
DOMContentLoaded. Load the adapter before the htmx<script>so the sweep listener registers first (loka-js's "orchestrator before libraries" rule). - Swapped-in content — a registered htmx v4 extension
(
lokascript-i18n) canonicalizes each subtree inhtmx_before_process(htmx_before_process_nodeis kept as an inert alias). (A best-effortdefineExtension/onEventfallback covers htmx v1/v2.)
Guarantees, mirroring loka-js where the mechanism allows:
- Authored attributes are never removed or rewritten — devtools shows what
the author wrote. Documented exceptions: an author-written canonical
hx-triggerwhose value uses localized event names (hx-trigger="clic") is translated in place (idempotently), since there is no separate canonical target; and in opt-in executor mode a canonical-namedhx-on:*attr may be removed so htmx never JS-evals its hyperscript body. On htmx v4 with the extension accepted that removal does not happen: the extension cancels the node'shtmx:before:on:initinstead and the claimed attr stays authored-verbatim (removal survives only as a per-node fallback when the element also carries an hx-on form htmx must still bind — the legacy compositehx-on="…", theconfig.prefixspelling, a custommetaCharacter). On htmx v2, or when v4 rejected the registration (htmx.config.extensionsallowlist), or with no htmx at all, the attr is removed at claim time — the only guard that needs no hook. Adapter-created canonical siblings are not authored and are always cleaned up. - An existing canonical attribute always wins —
hx-getis never overwritten byhx-obtener. - No vocab loaded → no-op. Stock htmx pages pay nothing.
Per-element language
Language resolves per element at canonicalization time (not per page):
data-hyperfixi-langon the elementdata-hyperfixi-langon any ancestorlangon any ancestor (HTML standard)'en'fallback
So <section lang="es"> and <section lang="ja"> coexist on one page, each
resolving its own vocabulary.
Programmatic API
import {
register, // register(lang, payload) — same shape as core's vocab modules
registerWith, // registerWith(htmx) → 'v4' | 'v2' | null
installAutoSweep, // initial-document sweep + re-sweep on late vocab
canonicalizeTree, // manual canonicalization of a subtree
langOf, // per-element language resolution
} from '@lokascript/htmx-adapter';The browser IIFE does all the wiring automatically and installs
window.__hyperfixi_i18n.register so the generated vocab modules
self-register. If the page already has a registry (core 3.x's
hyperfixi-hx.js), registrations fan out to both.
Hyperscript hx-on: bodies (executor mode, opt-in)
By default, hx-on:* bodies keep upstream semantics: they are JavaScript,
htmx executes them, and the adapter translates only the attribute name and
event suffix. JS is language-neutral — there is nothing to translate.
To author hyperscript bodies instead — including localized ones — opt in
by configuring an executor. The easiest way is auto-detection: load
_hyperscript (and, for non-English bodies, a
@lokascript/hyperscript-adapter language bundle) on the page and the adapter
wires itself:
<script src="_hyperscript.js"></script>
<script src="hyperscript-i18n-es.global.js"></script>
<!-- translator: HyperscriptI18n.preprocess -->
<script src="htmx-i18n.global.js"></script>
<script src="vocab/es.js"></script>
<script src="htmx.js"></script>
<section lang="es">
<button hx-obtener="/api" hx-objetivo="#out" hx-en:clic="alternar .cargando">…</button>
</section>Or wire it manually:
import { setBodyExecutor, setBodyTranslator } from '@lokascript/htmx-adapter';
setBodyExecutor((code, elt, evt) => _hyperscript.evaluate(code, { me: elt, event: evt }));
setBodyTranslator((body, lang) => HyperscriptI18n.preprocess(body, lang)); // optionalWith an executor set, the adapter claims the entire hx-on family (all
bodies are treated as hyperscript — mixed JS/hyperscript pages have no
reliable detection):
- It installs a real event listener per
hx-on-family attribute and runs the body through the executor. Translation (localized → English hyperscript) is lazy — first fire, memoized — and confidence-gated by the hyperscript-adapter preprocessor, so untranslatable bodies pass through unchanged. - Localized-named attrs (
hx-en:clic) stay verbatim in the DOM and get no canonical sibling — htmx never recognized them anyway. - Canonical-named attrs (
hx-on:click) must be kept away from htmx's own binder — left bound, htmx would eval the hyperscript body as JS, giving a console error plus a double-execution attempt on every fire. A claim records the attribute; what keeps htmx off it depends on the runtime:- htmx v4, extension accepted (the
registerWithresult, not the API's shape): no mutation. The extension'shtmx:before:on:inithook consults the record per node and cancels htmx's binding; the authored attribute stays in the DOM, andhtmx.process(elt, true)after editing it runs the new body. If the node also carries an hx-on form htmx must bind and the adapter never claims (compositehx-on="event -> code", theconfig.prefixspelling, a custommetaCharacter), per-node cancellation would kill that too, so the hook removes the claimed canonical attrs and lets htmx bind the rest. - htmx v2 (2.0.10 binds
hx-onbefore it firesbeforeProcessNode, so there is no pre-bind seam), a rejected v4 registration (the adapter warns, naminghtmx.config.extensions), or no htmx: the attr is removed at claim time — the documented exception to the never-mutate rule, and the only guard that does not depend on a hook being installed. - Wiring
createExtension()intohtmx.registerExtensionyourself? CallsetNeutralizeOnClaim(false)after it succeeds to get the v4 behaviour.
- htmx v4, extension accepted (the
- The
hx-on::after-swapshorthand maps to thehtmx:event namespace and works unchanged (the listener hears htmx's real CustomEvents). - Re-sweeps never stack duplicate listeners (claims are keyed per element by
resolved event name), and an executor configured after the initial sweep
triggers a healing re-sweep. The initial sweep waits for
DOMContentLoadedto have fired (aloadlistener covers a script injected in between), and the browser entry registers the extension before it re-detects_hyperscript, so a late_hyperscript, adefered adapter, or a module script all see the same behaviour as the recommended order.
A side benefit: executor mode never uses eval, so hyperscript bodies work on
CSP-restricted pages where htmx's native hx-on JS eval cannot.
hx-live bodies stay out of scope: upstream v4's re-execution semantics are
internal to htmx's reactivity — name translation only.
Scope (v1)
Attribute names (hx-obtener → hx-get, including the hx-on: colon
family) and event values in hx-trigger / hx-on: suffixes
(clic → click), plus opt-in hyperscript hx-on: bodies via executor
mode (above). The hx-/sse-/ws- prefixes are preserved across languages —
only the suffix is localized. The _= attribute is
@lokascript/hyperscript-adapter's job, not this package's.
Anything read off an attribute name is matched the way HTML matches names:
the parser lowercases ASCII letters in them, so Portuguese's camelCase events
work as authored (hx-em:teclaBaixo → hx-on:keydown, although the element
carries hx-em:teclabaixo). Event names in hx-trigger values keep exact,
case-sensitive matching, as DOM event names do.
Tests
npm test --prefix packages/htmx-adapter # vitest, jsdom
npm run test:browser --prefix packages/htmx-adapter # Playwright e2e (build dist first)The unit suite loads every generated vocab/{lang}.js module against this
adapter's registry, and its drift gate (test/vocab-generator.test.ts, also
npm run check:vocab) fails when a committed module differs from what the
generator emits — so a profile or dictionary change that moves a name fails
here rather than in a browser.
The Playwright suite drives real vendored libraries — htmx 4.0.0,
htmx 2.0.10, _hyperscript 0.9.93 (test/browser/vendor/) — verifying the
end-to-end truths mocks can't: the v4 extension hook name and firing
granularity (htmx_before_process, per processed root — validated against the
4.0.0 source), request/swap from a localized button, both script orders,
localized attributes inside swapped-in content, executor-mode hx-on bodies
running through real _hyperscript with me bound, and the htmx 2.x
defineExtension fallback.
