Experimental HTMX

Experimental

This module uses advanced Julia metaprogramming (macros, @eval code generation, Base.getproperty overloading) to compress the full 625-line HTMX API into under 100 lines of code. The interfaces are experimental and may evolve.

The Experimental module provides three interfaces for working with HTMX attributes. All three can produce identical results — choose the style that fits your workflow.

Note

The @hx macro sets raw string values for all attributes. Trigger modifiers like once, delay, etc. must be included directly in the string (e.g., trigger="click once") rather than via keyword arguments.

Setup

The experimental module is included separately from the main HTMX API:

using HTMLForge
include(joinpath(pkgdir(HTMLForge), "src", "Experimental.jl"))

Interface 1: Classic Functions

All hx*! functions from the standard API are auto-generated via @eval loop over an attribute registry. They work identically to the standard API:

el = HTMLElement(:button)
hxget!(el, "/api/data")
hxpost!(el, "/submit")
hxtarget!(el, "#result")
hxswap!(el, "outerHTML"; transition=true, settle="100ms")
hxtrigger!(el, "click"; once=true, delay="500ms")
hxconfirm!(el, "Are you sure?")

Flag attributes (no value needed):

hxdisable!(el)     # data-hx-disable=""
hxpreserve!(el)    # data-hx-preserve=""
hxvalidate!(el)    # data-hx-validate=""

How it works

A registry Dict{Symbol, Tuple{String, Symbol}} maps attribute names to their htmx suffix and kind (:v for value-taking, :f for flag). A single for loop with @eval generates all 30+ functions at compile time:

for (name, (attr, kind)) in _R
    fn, full = Symbol(:hx, name, :!), "data-hx-" * attr
    if kind == :v
        @eval \$fn(el::HTMLElement, v::AbstractString) = (el[\$full] = v; el)
    else
        @eval \$fn(el::HTMLElement) = (el[\$full] = ""; el)
    end
end

Interface 2: @hx Macro

The @hx macro enables declarative, multi-attribute assignment in a single expression.

Creating a new element

Pass a symbol to simultaneously create an element and set attributes:

btn = @hx :button get="/api" trigger="click" target="#result"
# Equivalent to:
#   btn = HTMLElement(:button)
#   btn["data-hx-get"] = "/api"
#   btn["data-hx-trigger"] = "click"
#   btn["data-hx-target"] = "#result"

Modifying an existing element

Pass a variable to modify it in place:

el = HTMLElement(:form)
@hx el post="/submit" swap="outerHTML" confirm="Sure?"

Underscores become hyphens

Use underscores in attribute names — they are automatically converted to hyphens:

@hx el push_url="true" replace_url="/new"
# → data-hx-push-url="true", data-hx-replace-url="/new"

Expression interpolation

Values can be any Julia expression:

url = "/api/v2/items"
btn = @hx :button get=url

How it works

The macro inspects its first argument at compile time:

  • QuoteNode (e.g. :button) → generates HTMLElement(:button)
  • anything elseesc(el) to use the existing variable

It then iterates over the key=value pairs, converting them to el["data-hx-key"] = string(value) assignments. The entire expansion is a begin...end block returning the element.

Interface 3: hx.* Pipe DSL

The most ergonomic interface — chain htmx attributes with Julia's |> operator using the hx singleton:

el = HTMLElement(:button) |>
    hx.post("/api/submit") |>
    hx.trigger("click"; once=true) |>
    hx.target("#response") |>
    hx.swap("outerHTML"; transition=true, settle="200ms") |>
    hx.confirm("Proceed?") |>
    hx.indicator("#spinner")

Value attributes

Every registered attribute is accessible as hx.<name>(value):

el |> hx.get("/api")
el |> hx.target("#out")
el |> hx.boost("true")
el |> hx.pushurl("true")
el |> hx.headers("{\"X-Token\": \"abc\"}")

Flag attributes

Flag attributes take no arguments:

el |> hx.disable()
el |> hx.preserve()
el |> hx.validate()

Complex attributes

hx.trigger, hx.swap, and hx.on support the same keyword arguments as their function counterparts:

el |> hx.trigger("keyup"; delay="300ms", changed=true)
el |> hx.swap("innerHTML"; transition=true, focusScroll=false)
el |> hx.on("click", "console.log('clicked')")

How it works

hx is a zero-size singleton struct _HxPipe. A Base.getproperty override intercepts hx.foo and returns a curried closure: calling hx.get("/api") returns el -> (el["data-hx-get"] = "/api"; el), which is exactly the signature |> expects. For trigger and swap, the closures forward keyword arguments to the full hxtrigger! / hxswap! implementations.

Complete Comparison

Here's the same element built with all three interfaces:

# Classic
el = HTMLElement(:button)
hxpost!(el, "/submit")
hxtarget!(el, "#result")
hxswap!(el, "outerHTML")
hxtrigger!(el, "click"; once=true)
hxconfirm!(el, "Sure?")

# @hx macro — trigger modifiers are included in the raw string
el = @hx :button post="/submit" target="#result" swap="outerHTML" trigger="click once" confirm="Sure?"

# Pipe DSL
el = HTMLElement(:button) |>
    hx.post("/submit") |>
    hx.target("#result") |>
    hx.swap("outerHTML") |>
    hx.trigger("click"; once=true) |>
    hx.confirm("Sure?")
Tip

The Classic and Pipe interfaces build trigger/swap modifiers via keyword arguments. The @hx macro sets all values as raw strings — include the full hx-trigger or hx-swap value directly (e.g., trigger="click once delay:500ms").

Supported Attributes

Value Attributes (take a string argument)

Functionhtmx attributePipe
hxget!data-hx-gethx.get(url)
hxpost!data-hx-posthx.post(url)
hxput!data-hx-puthx.put(url)
hxpatch!data-hx-patchhx.patch(url)
hxdelete!data-hx-deletehx.delete(url)
hxtarget!data-hx-targethx.target(sel)
hxselect!data-hx-selecthx.select(sel)
hxswapoob!data-hx-swap-oobhx.swapoob(v)
hxselectoob!data-hx-select-oobhx.selectoob(v)
hxvals!data-hx-valshx.vals(json)
hxpushurl!data-hx-push-urlhx.pushurl(v)
hxreplaceurl!data-hx-replace-urlhx.replaceurl(v)
hxconfirm!data-hx-confirmhx.confirm(msg)
hxprompt!data-hx-prompthx.prompt(msg)
hxindicator!data-hx-indicatorhx.indicator(sel)
hxboost!data-hx-boosthx.boost(v)
hxinclude!data-hx-includehx.include(sel)
hxparams!data-hx-paramshx.params(v)
hxheaders!data-hx-headershx.headers(json)
hxsync!data-hx-synchx.sync(v)
hxencoding!data-hx-encodinghx.encoding(v)
hxext!data-hx-exthx.ext(v)
hxdisinherit!data-hx-disinherithx.disinherit(v)
hxinherit!data-hx-inherithx.inherit(v)
hxhistory!data-hx-historyhx.history(v)
hxrequest! (config)data-hx-requesthx.request(v)
hxdisabledelt!data-hx-disabled-elthx.disabledelt(sel)

Flag Attributes (no argument)

Functionhtmx attributePipe
hxdisable!data-hx-disablehx.disable()
hxpreserve!data-hx-preservehx.preserve()
hxvalidate!data-hx-validatehx.validate()
hxhistoryelt!data-hx-history-elthx.historyelt()

Complex Attributes (with keyword arguments)

FunctionPipeSupports kwargs
hxtrigger!(el, event; ...)hx.trigger(event; ...)once, changed, delay, throttle, from, target, consume, queue, filter
hxswap!(el, style; ...)hx.swap(style; ...)transition, swap, settle, ignoreTitle, scroll, show, focusScroll
hxon!(el, event, script)hx.on(event, script)

Metaprogramming Techniques Used

TechniqueWherePurpose
@eval in a loopFunction generationGenerate 30+ hx*! functions from a registry Dict
Symbol arithmeticSymbol(:hx, name, :!)Dynamic function naming
macro with AST introspection@hxDetect QuoteNode vs variable to choose creation vs modification
Base.getproperty overloadinghx.* pipe DSLCreate a callable namespace on a zero-size singleton
Curried closuresPipe DSLReturn el -> ... functions compatible with |>