Skip to content

HTMXObjects.jl ​

Property-based web apps for Julia. Built on DynamicObjects.jl, Oxygen.jl, and HTMX.jl.

PageWhat it covers
This pageThe core idea (@dynamicstruct data + @htmx requests), routing, derived properties, response pipeline, error handling, async/polling, anti-patterns
ExamplesHello, Counter, Blog, Search, Tabs — each with source links
ComponentsCatalog of every form input, table renderer, layout widget, and helper — with minimal usage snippets
TestingMounting TestRoutes, the test_* runner functions, the web-integrated test workflow
APIFull export list grouped by use case

One idea ​

Every HTMXObjects app is split along a single axis:

  • Data — persistent, lives for the server's lifetime. Collections, filesystem handles, expensive computations, mutable caches. Lives on @dynamicstruct types. This is the backend.

  • Requests — ephemeral, regenerated on every HTTP request. URL handlers, HTML fragments, request-derived parameters, page layouts. Lives on @htmx types. This is the UI.

A fresh @htmx instance is built for every inbound request, injected with the inbound HTTP.Request, and discarded after the response is sent. It reaches through to the single long-lived @dynamicstruct tree for anything that needs to survive between requests. Nothing ephemeral leaks into data; nothing persistent leaks into requests.

Keeping this split clean is the whole discipline:

@dynamicstruct (data, backend)@htmx (requests, UI)
Built once, at module initBuilt fresh per request
Vectors, DataFrames, @cached resultsHTML fragments and page layouts
File / DB / network handles@get / @post / … route properties
Thread-safe mutation (cache_type=:parallel)@param request-derived values
No HTTP knowledgeHas __req__, __prefix__, __appdata__

Everything else in this document — @include, @param, query_url, the response pipeline, error handling — exists to make that split ergonomic.

Quick start ​

Scaffold:

julia
using HTMXObjects
create_app("MyApp.jl")    # writes Project.toml, src/MyApp.jl, app/

Run:

bash
cd MyApp.jl && julia -i --project=app app/main.jl

A minimal app written by hand:

julia
module MyApp

using HTMXObjects

# 1. Data — persistent, lives for the server's lifetime.
@dynamicstruct struct AppData
    items::Vector{String} = ["alpha", "beta", "gamma"]
end

const APPDATA = AppData()

# 2. Requests — fresh instance per HTTP request.
@htmx struct AppRoutes
    __appdata__ = APPDATA
    (; items) = __appdata__

    __page__(content) = htmx(h.main(; class="container")(content); pico_version="2")

    @get index() = h.div(
        h.h1("Items"),
        h.ul([h.li(x) for x in items]...),
    )
end

function __init__()
    route!(AppRoutes())
end

end

That is the whole shape. A @dynamicstruct holds the list. A @htmx struct declares / → GET rendering HTML. Revise reloads on save; the server never needs to restart.

Data layer — @dynamicstruct ​

Persistent state. Built once when the module loads; lives until the process exits. Use it for anything that is expensive to build, mutable, or needs to outlive a single request.

julia
@dynamicstruct struct AppData
    items::Vector{String}    = ["alpha", "beta"]
    counter::Ref{Int}        = Ref(0)
    dataset                  = load_big_dataframe()   # run once at construction
    compute_result(x)        = expensive(x)           # memoised per argument
end

const APPDATA = AppData()

Conventions ​

  • One const APPDATA = AppData() at module top level. It is the only module-level const the app needs.

  • Feature-scoped sub-structs. For anything non-trivial, give each feature its own XData @dynamicstruct and compose them as properties of AppData. See BayesianRegressionModels (Formula, Dataset, ExampleEntry, AppData) for an example.

  • No standalone top-level functions in the web module. If a function uses app state, make it a derived property on the owning @dynamicstruct. If it is genuinely stateless, inline it or put it in the underlying package.

  • No module-level Dict{K,V}(). Oxygen serves requests concurrently. Use a @dynamicstruct with cache_type=:parallel or a ThreadsafeDict field.

  • No module-level mutable state. Mutate through property accessors on AppData so Revise can hot-reload the definition without losing data.

See the DynamicObjects docs for the full property grammar (@cached, @persist, indexed properties, inline @struct children, docstring interpolation).

Request layer — @htmx ​

Fresh instance per request. The framework wires in __req__, __prefix__, and falls __appdata__ through from the parent. Everything else is user code. A @htmx struct is a @dynamicstruct plus:

  • Route markers (@get, @post, @put, @patch, @delete, @ws) turn properties into HTTP endpoints.

  • @include composes sub-structs under a path segment.

  • @param declares request-derived typed properties shared across routes.

  • A _reroute! hook fires on Revise re-evaluation so routes re-register without a server restart.

julia
@htmx struct AppRoutes
    __appdata__ = APPDATA
    (; items) = __appdata__

    # Page layout — called by the response pipeline on direct browser nav.
    __page__(content) = htmx(h.main(; class="container")(content); pico_version="2")

    # Sibling references are rewritten to `__self__.x` by the macro, so a
    # derived property can reference other properties by bare name.
    title = "My items"
    header = h.header(h.strong(title))

    @get index()  = h.main(header, h.ul([h.li(x) for x in items]...))
    @get show(id) = h.article(h.header("Item "), h.code(id))
end

A property with a RHS is derived and lazily memoised on the instance (i.e. for the duration of one request). A property without a RHS (items::Vector{String}) is a fixed field — rarely needed on a @htmx struct.

Route properties can take positional path params (show(id)) or kwargs (search(; q="") — auto-extracted from query or form). Type annotations trigger parse(T, ...) automatically.

Wiring data to requests — __appdata__ {#Wiring-data-to-requests-—-appdata} ​

__appdata__ is the bridge. The root @htmx struct sets it; every nested @included struct inherits it through the __parent__ chain. Destructure what you need at the top of each routes struct and refer to local names everywhere else.

julia
@dynamicstruct struct AppData
    items = ["alpha", "beta"]
    counter = Ref(0)
    dataset = load_big_dataframe()
end
const APPDATA = AppData()

@htmx struct AppRoutes
    __appdata__ = APPDATA
    (; items, counter, dataset) = __appdata__   # destructure once
    @get index() = h.p("count: $(counter[])")
    # use `items` / `dataset` directly — never `__appdata__.items`
end

If initialisation needs runtime values (env reads, file I/O), use const APPDATA = Ref{AppData}() and assign it in __init__.

Composition — @include ​

Group related routes into a sub-@htmx struct, mount it under a path segment.

External form — reuse a named type:

julia
@htmx struct PipelineRoutes
    (; dataset) = __appdata__           # falls through via __parent__
    @get index()  = h.p("rows: $(nrow(dataset))")
    @post submit() = ...
end

@htmx struct AppRoutes
    __appdata__ = APPDATA
    @include pipeline = PipelineRoutes()   # mounts PipelineRoutes at /pipeline
end

Inline form — keep a tightly scoped feature in one place:

julia
@htmx struct AppContext
    __appdata__ = APPDATA
    (; examples_dir) = __appdata__

    @include examples = begin
        @struct example_store = begin     # inline DO sub-store
            entries() = [ExampleEntry(f) for f in readdir(examples_dir; join=true)]
            find(label) = findfirstelement(e -> e.label == label, entries())
        end

        @get index() = h.ul([h.li(e.label) for e in example_store.entries()]...)
    end
end

Both forms thread __req__, __appdata__, and __prefix__ automatically:

  • __parent__ = __self__ — children see the enclosing struct.

  • __prefix__ = parent.__prefix__ * "/<name>" — each @include appends one segment.

  • __appdata__ falls through the __parent__ chain via getproperty.

Destructure once at the top, reach up via __parent__ {#Destructure-once-at-the-top,-reach-up-via-parent} ​

The root destructures from __appdata__. Deeper children reach up through __parent__ to the slices their parent already destructured. Never re-dig through __appdata__.feature.items in every route body.

julia
@htmx struct AnalysisRoutes
    (; dataset, fit_resolver) = __appdata__
    (; chain_analysis, posteriors_data) = dataset
    @include chains     = ChainsRoutes()       # sees __parent__ = this
    @include posteriors = PosteriorsRoutes()
end

@htmx struct ChainsRoutes
    (; chain_analysis, fit_resolver) = __parent__    # up to AnalysisRoutes
    @param (; fit_key) = __parent__                  # see @param delegation
end

Routes ​

A verb marker turns a property into a route. The property name becomes the URL path (minus the :index special case, which maps to the struct's mount point).

julia
@htmx struct AppRoutes
    @get  index()         = list_view()                # GET /
    @get  item(id)        = render_item(id)            # GET /item/{id}  (id::String)
    @get  page(id::Int)   = render_page(id)            # GET /page/{id}  (auto-parsed to Int)
    @get  search(; q="", n::Int=1) = ...               # GET /search?q=&n=
    @post submit(; name="") = persist!(name)           # POST /submit (form-encoded)
    @get  range(a, b=1)   = ...                        # GET /range/{a}/{b} AND /range/{a}
    @delete remove(id)    = drop!(id)                  # DELETE /remove/{id}
    @ws     feed()        = (__ws__) -> ...            # WebSocket at /feed
end

Rules ​

  • Call form is mandatory. Every route LHS must be a call form (name() / name(arg) / name(; kw=…)). Bare form (@get index = body) is rejected at macro-expansion — the macro injects ::Verb{V} as the first positional arg and needs a call AST to do so. Bracket form (@get foo[bar]) is the DO IP-index syntax and also not valid for routes — @get foo[bar](; q="") hits a DO assertion because Julia parses ; inside [] as matrix concatenation.

  • :index collapses the URL segment. @get index() = … mounts at the enclosing prefix itself (no /index segment); @get index(slug) mounts at <prefix>/{slug}.

  • Untyped params are String. Annotate with ::Int, ::Float64, ::Bool, ::Symbol, or ::Vector{T} for auto-parsing. Extend _convert_param(val::AbstractString, ::Type{T}) to support new types.

  • Trailing positional defaults register shortened routes automatically. @get range(a, b=1) handles both /range/{a}/{b} and /range/{a}. Do not write two @get properties.

  • Kwargs sourcing — queryparams(req) on GET/DELETE; formdata(req) with queryparams fallback on POST/PUT/PATCH.

  • Multi-value query params (?tag=a&tag=b) — untyped kwargs receive Vector{String}; Vector{T}-typed kwargs coerce each element; scalar- typed kwargs receive the first value.

  • Multi-verb routes on one name are allowed — @get user(id) + @put user(id) + @delete user(id) all register at /user/{id}. The verb is encoded as a Verb{V} singleton injected as the first positional arg of every route LHS, so multiple compute_property methods on the same property name coexist. There is no name mangling; only exact (name, verb) duplicates are rejected.

Register with route! ​

julia
route!(app)                          # register routes
route!(app; record_dir="site")       # + record every response to disk
route!(app; prefix="api")            # mount everything under /api

There is no appdata kwarg — shared state is threaded through __appdata__. route! stores the type in _registered_types; the _reroute! hook emitted by @htmx re-registers automatically when Revise reloads the struct.

Request-derived state — @param ​

@param name::T = default is a derived property that pulls itself from the current request (same extraction rules as @get/@post kwargs — shared _lookup_param primitive). It is memoised per instance (i.e. per request). Use it when several routes in the same struct (or inline children) need the same params.

julia
@htmx struct Analysis
    @param vessels::Vector{String} = ["Tablet-20"]
    @param fit_key::String                           # required — KeyError on miss
    @param note                    = "hi"            # untyped → raw String/Vector{String}

    @param begin
        subjects::Vector{String} = String[]
        top_chains::Int          = 4
    end

    @get index() = render(vessels, subjects, top_chains)
    @get plot()  = build_plot(vessels, fit_key)
end

Rules ​

  • Never wrap with @cached. @param is per-request state; @cached would persist to disk and leak across requests.

  • Required params throw KeyError(:name) on access.

  • Defaults are full Julia expressions. Module-level variables and function calls work (unlike @get kwarg defaults, which are limited to literals).

  • Inline @include children inherit parent @params for free. External children do not — use the delegation form:

julia
@htmx struct ChildRoutes
    @param (; fit_key, top_chains) = __parent__   # register + resolve
    (; plot_height)                = __parent__   # plain destructure, not a @param
end

query_url(path, obj) — round-tripping params ​

julia
@get plot() = begin
    poll_url = query_url(__self__ / "data", __self__)
    h.div(hx_get=poll_url, hx_trigger="every 1s")(...)
end

query_url(path, obj) iterates _param_names(typeof(obj)) and emits only the params that were actually present in obj.__req__. Explicit kwarg overrides always win. Defaults stay implicit — the URL only carries what the user set.

query_url(path; kwargs...) (single argument) just builds a plain URL with escaped query parameters.

Response pipeline ​

Every route's return value flows through _resolve_response unless it is already an HTTP.Response. Precedence (first match wins):

  1. HTTP.Response → returned as-is.

  2. ?error on the query string → filter_errors(val) keeps only e.* nodes and their ancestors.

  3. Markdown requested (?markdown, ?plain, or Accept: text/markdown/ text/plain) → to_markdown_string(val).

  4. HTMX request (HX-Request: true) → fragment returned as-is.

  5. Otherwise → wrap in __page__(content) if any ancestor defines one.

Never branch on request mode inside a route body. Return bare content; the framework handles the rest:

julia
# BAD — double-wraps browser responses.
@get index() = __page__(h.div(...))

# BAD — the pipeline already does this.
@get item(id) = is_htmx(__req__) ? fragment : __page__(fragment)
@get item(id) = if wants_markdown(__req__) markdown else html end

# GOOD
@get index() = h.div(...)

__page__ — the full-page wrapper {#page-—-the-full-page-wrapper} ​

Define it on the root @htmx struct (or any ancestor). It gets called with whatever content a browser route returned, for full-page renders:

julia
__page__(content) = htmx(
    h.div(; class="layout")(
        nav_sidebar(["Pipeline" => "/pipeline", "Examples" => "/examples"]),
        h.main(; class="container")(h.div(; id="content")(content)),
    );
    pico_version="2",
    extra_head=(h.title("My app"), h.style(my_css)),
)

Same route serves humans and agents ​

The markdown branch means the same route handles both browsers (HTML) and agents (markdown via curl -H "Accept: text/markdown" or ?plain). For mode-specific bits inside shared content, wrap with html_only(...) / markdown_only(...):

julia
h.div(
    h.h1("Dashboard"),                    # both
    html_only(h.nav(buttons...)),         # HTML only
    markdown_only("*hint: use ?plain*"),  # markdown only
    h.table(data...),                     # both (auto-converts)
)

Magic properties ​

Framework-recognised names. Users don't declare them — the macro / request handler injects them — but route bodies may reference them.

NameSet byPurpose
__self__@dynamicstructThe current struct instance.
__req__route! handler (per request)The inbound HTTP.Request.
__ws__@ws body wrapperThe WebSocket handle inside @ws bodies.
__parent__@include desugarParent struct instance in a sub-struct.
__prefix__route! + @includeCurrent mount path.
__route__route! handler (per request)The per-request URL (query string stripped). Use for hx_get=__route__ / href=__route__ instead of recomputing.
__appdata__User (= APPDATA) + @includeApp data; falls through the __parent__ chain.
__page__Usercontent -> full_page(content). Called by pipeline.
__error__User (optional)err -> renderable. Called on caught exceptions.

URL building — __self__ / "path" {#URL-building-—-self-/-"path"} ​

Every @htmx struct carries __prefix__ — route! seeds the root, @include appends /<name> at each level. Build URLs through __self__/"..." or __parent__/"..." so mount changes (route!(..; prefix="api")) and nesting changes don't break links:

julia
h.a(href    = __self__/"about")           # full path at this mount point
h.form(hx_post = __self__/"submit")        # POST back to this sub-struct

@query_url prop(args; kw=val) follows @get conventions: positional args become path segments, kwargs become query params. Useful for polling:

julia
h.div(hx_get=@query_url(stage(:bench; force=true)), hx_trigger="every 1s")(...)

Caching — @cached and @persist ​

@cached persists results to disk under cache/<hash>/<index>.sjl. The key includes the struct's fixed fields, so every per-request AppRoutes() sees the same persisted value.

julia
@htmx struct AppRoutes
    @cached running = false

    @post toggle() = begin
        running = !running               # rewritten to __self__.running = !running
        @persist running                 # flush to disk
        render_ui()
    end
end

Most @cached usage belongs on the data side (AppData). On @htmx structs, prefer it for genuinely app-wide toggles (running/stopped, last-submitted formula, etc.), never for per-request state — that is what @param is for.

Async work and polling — IPs + polling_fetchindex ​

Long-running computations (sampling, compilation, parameter fits) must not block the HTTP request handler. HTMXObjects apps handle this by driving IndexableProperties (IPs) on the data side through polling_fetchindex on the routes side. Three facts anchor the pattern — read carefully, because conflating them is the single most common async bug:

  1. Every function-form DO property is an IP. Any LHS with parens — name() = expr, name(x) = expr, name(x; kw=1) = expr — produces an IP. The parens are what matter; arguments are not required. A zero-arg function form name() = expr is still an IP. IPs are backed by a ThreadsafeDict (under cache_type=:parallel, the usual setting for AppData), spawn and deduplicate Tasks per key, and are pollable.

  2. Bare-property form (name = expr) is NOT an IP. It is a lazy-memoised derived value on the instance. Not pollable. If you need to poll something, lift it to function form — even if it takes no arguments.

  3. @cached ONLY adds disk serialisation. It does not create IPs and IPs do not require it. A function-form property is already an IP and already pollable without @cached. Use @cached when (and only when) you want the result to survive a server restart.

The canonical pattern ​

julia
# Data side: function-form property on AppData is the IP.
@dynamicstruct struct AppData
    # ... fields ...

    # Function form → IP. No @cached, no wrapping macro. This alone makes
    # it a ThreadsafeDict-backed, task-spawning, pollable computation.
    compute_steps(text, namespace, name::Symbol) = begin
        ...                                            # slow work
        result                                          # whatever the UI renders
    end
end

const APPDATA = AppData(; cache_type=:parallel)

# Routes side: polling_fetchindex drives the IP from an HTMX route.
@htmx struct PipelineRoutes
    (; compute_steps) = __appdata__
    @param formula::String = ""
    @param label::String   = ""

    @get stage(name::Symbol; force::Bool=false) = polling_fetchindex(
        compute_steps, formula, namespace_from(label), name;
        poll_url = query_url(__self__/"stage/$name"; formula, label),
        label    = "Pipeline - $name",
        force,
    ) do result
        # This block runs only once the IP resolves.
        render(result)
    end
end

What polling_fetchindex(ip, args...; poll_url, label, force=false) do result ... end actually does:

  • First call: kicks off the Task for @memo ip(args...) (via ThreadsafeDict.get! under the hood — deduplicating concurrent requests for the same args), and returns a progress fragment with hx-get=poll_url + hx-trigger="every Ns". The HTMX client polls.

  • Subsequent polls hit poll_url and re-enter the handler:

    • Still running → same progress fragment.

    • Failed → error article.

    • Resolved → the do result block's rendering.

  • force=true invalidates the cached entry for those args and re-runs the task.

The route itself is just a plain @get returning a fragment. No branching on request mode; the response pipeline handles the rest.

Prepopulating the progress tree — @progress and prepare_progress! ​

polling_fetchindex gives the user a live progress view while the IP runs. By default they see a single node that fills in as the IP's body executes. For multi-stage pipelines, you almost always want all phases visible as pending up front so the user can see the whole shape before anything finishes. Two variants, pick by how the phase list is known:

Static list — @progress blocks. When the phases are literal in the source, use Treebars' @progress macro. Every @progress "label" marker inside a @progress begin … end block is pre-enumerated as a pending child before the block starts, then starts/finishes in order:

julia
using Treebars: @progress

compute_all = (text) -> begin
    @progress "Pipeline" begin
        @progress "Parse";       raw  = Meta.parse(text)
        @progress "Transform";   tx   = transform(raw)
        @progress "Compile";     lib  = compile(tx)
        @progress "Fit";         draws = fit(lib)
        (; raw, tx, lib, draws)
    end
end

All four labels appear immediately as pending siblings under "Pipeline"; each becomes active/finished as the block advances.

Data-driven list — prepare_progress! + with_prepared_progress. When the phase set depends on runtime values (the user picked a stage from a DAG, or the chain length varies with inputs), build the pending nodes by hand and sandwich each phase:

julia
using Treebars: prepare_progress!, with_prepared_progress

compute_steps(text, namespace, name::Symbol) = begin      # IP on AppData
    r      = run(text, namespace)
    chain  = step_chain[name]                             # NamedTuple: step_key => spec
    # Pre-create one pending ProgressNode per step, attached to this IP's
    # own __status__. All of them show up as dim "pending" siblings
    # immediately; the user sees the full pipeline shape before anything
    # starts.
    phases = [prepare_progress!(__status__; description=string(k))
              for k in keys(chain)]
    vals = map(pairs(chain), phases) do (step_name, spec), phase
        # Start this phase, run the body, finalize/fail on exit.
        with_prepared_progress(phase) do progress
            # progress is the live ProgressNode for this phase.
            # Work done here attaches under it (see next section).
            resolve(r, spec)
        end
    end
    merge((; data=r.df), NamedTuple{keys(chain)}(Tuple(vals)))
end

This is what BRM's compute_steps IP does. with_prepared_phases(f, parent, descriptions) is a bulk helper if you'd rather skip the prepare_progress! / with_prepared_progress boilerplate.

Nesting IP progress — fetchindex!(status, ip, args...) ​

Inside a phase body you will often access another IP whose own computation is itself long-running and reports progress (an MCMC sampler, a Pathfinder init, a remote fetch). Calling @memo other_ip(args...) would work — the inner IP kicks off its own Task and its progress attaches to nothing visible. What you want is for the inner IP's progress to nest under the current phase so the user sees one tree instead of orphaned siblings.

fetchindex! is the primitive for that (exported from DynamicObjects; the Treebars-attaching method lives in DynamicObjects/ext/TreebarsExt.jl and activates when Treebars is using'd):

julia
# Two methods:
fetchindex!(::Nothing, ip, indices...)              # cache-access only — no progress attachment
fetchindex!(status::ProgressNode, ip, indices...)   # attach the IP's substatus under `status`

The non-nothing form attaches the inner IP's progress subtree as a child of status before returning. Used inside with_prepared_progress:

julia
with_prepared_progress(phase) do progress
    if step_name === :stan_fit_pathfinder
        # Pathfinder is a function-form property (IP). Fetching it under
        # `progress` nests the sampler's own progress subtree under this
        # phase node.
        fetchindex!(progress, r.stan.pathfinder, r.stan.fit_instance, r.stan.init)
        resolve(r, spec)                   # reads the cached value via @memo
    else
        resolve(r, spec)
    end
end

Known users of the non-nothing form:

  • BRM's compute_steps — warms the pathfinder and posterior_warmup IPs under the matching phase's progress.

  • bruno's _rpkpd (web-pkpd/src/analysis/sim_state.jl) — forwards the caller's status into pkpd's IP fetch so the sampling progress nests correctly when called from a route that already opened a phase.

Pattern shorthand inside an IP body:

  • Always forward the current phase's progress into inner IP fetches — @memo ip(args) on its own orphans the subtree.

  • Use fetchindex!(progress, ip, args...) to warm and attach; then read the cached value back via @memo ip(args) on later lines if you need it. Never reach for bare ip[args] — see the access-form note below.

Translating the rules into action ​

  • Make a property pollable → lift it to function form (name(args...) = expr or name() = expr). Do not add @cached for pollability.

  • Make a property survive a restart → add @cached (disk persistence), independently of whether it's an IP.

  • Pre-populate the progress tree → @progress "label" markers for statically-known phases, prepare_progress! + with_prepared_progress (or with_prepared_phases) for data-driven phase sets.

  • Nest an inner IP's progress under the current phase → fetchindex!(status, ip, args...). @memo ip(args) on its own skips attachment and the sub-tree floats.

  • Call an IP from inside an HTTP request handler → never block. Use polling_fetchindex(ip, args...; poll_url, label) do result ... end.

  • Call an IP from inside another DO property body → blocking is fine (the outer body is itself in a spawned Task). Use fetchindex!(progress, ip, args...) so progress nests; fetchindex(ip, args...) or @memo ip(args) if you don't need progress.

  • Prefer @memo, not [], for cached access. o.prop(args) is fresh-each-call — runs the body every time. @memo o.prop(args) is ThreadsafeDict-cached per key (what fetchindex / polling_fetchindex dispatch through). @memo is the public face of the caching; avoid reaching for bare o.prop[args] directly. The brackets work — in fact @memo rewrites to them — but writing [] at the call site hides the fact that something magical (Task spawn, memoised lookup) is happening. Treat prop[args] as an implementation detail that @memo wraps.

See BRMMacroWeb (compute_steps IP + PipelineRoutes.stage route) and the pathfinder / posterior_warmup IPs under AppData.run.stan for worked examples.

Error handling ​

Where errors go — /tmp/htmxo_errors/<uid>.log ​

Every caught route exception writes a full report to /tmp/htmxo_errors/<uid>.log by default. That is the first place to look when a route returns the framework's error article. The in-browser article only shows the short <uid> — the uid, ISO timestamp, request method + target, and full showerror(io, err, bt) backtrace all live in the log file on disk.

/tmp/htmxo_errors/<uid>.log          ← default; tempdir() + "htmxo_errors"
$HTMXO_ERROR_DIR/<uid>.log           ← if the env var is set

The exact directory is held in ERROR_DIR[] (a Ref{String}), set by __init__ from the HTMXO_ERROR_DIR environment variable, falling back to joinpath(tempdir(), "htmxo_errors") — which resolves to /tmp/htmxo_errors on Linux and macOS. You can reassign ERROR_DIR[] at runtime; no auto-rotation, no size cap.

The uid is a short base-16 hash of time_ns() — same uid printed in the error article, emitted in the @error log line, and used as the filename. Grep the uid from your terminal/log viewer back to the file in one click.

What happens on exception ​

Every route handler is wrapped in try/catch. On exception, HTMXObjects:

  1. Generates a short uid (hash(time_ns())).

  2. Writes $ERROR_DIR[]/<uid>.log (default /tmp/htmxo_errors/<uid>.log) containing uid, ISO timestamp, method + target, and showerror(io, err, bt).

  3. Emits one @error "HTMXObjects caught an error: <full path>" — terse; the full stack trace is on disk.

  4. Feeds the result of __error__(err) (or the default renderer) back through the response pipeline.

Status codes ​

  • HTMX requests → HTTP 200 so vanilla HTMX still swaps the error article in without needing response-targets.

  • Non-HTMX requests (curl, direct browser nav, uptime checks) → HTTP 500 so logs and monitors see errors as errors.

  • If your __error__ hook returns an HTTP.Response directly, its status is respected — no rewrite.

Customising ​

julia
@htmx struct AppRoutes
    # Opt out — let exceptions propagate to Oxygen's 500 handler:
    # __error__ = rethrow

    # Custom rendering:
    __error__(err) = h.article(h.header("Oops"), h.p(sprint(showerror, err)))
end

Widget-level containment — safely ​

For composite routes (dashboards with several independent panels), wrap each panel in safely so one failure doesn't kill the others:

julia
@get dashboard() = h.div(
    safely(; obj=__self__) do
        render_ppc_plot(data)
    end,
    safely(; obj=__self__) do
        render_summary(data)
    end,
)

Error-tagged HTML — e.* ​

e.<tag> is the error-tagged parallel to h.<tag> (adds data-error="true"). The ?error filter keeps only data-error nodes and their ancestors — useful for exposing machine-readable error summaries from a composite page.

Static recording & replay ​

julia
route!(app; record_dir="site")

Every response is saved to disk mirroring the URL path (/post/42 → site/post/42.html). static_transform walks the Node tree and:

  • Strips hx-post, hx-put, hx-patch, hx-delete attributes.

  • Strips hx-get attributes whose URL contains ? or points at a kwargs-only route.

  • Marks affected elements with data-static-disabled + disabled, and injects a <style> block dimming them.

After recording, replay with any static server:

bash
python -m http.server --directory site

Revise hot-reload ​

ChangeHot-reloaded?
Plain function in the package / web moduleYes
@dynamicstruct / @htmx struct bodyYes
New @get/@post added to a structYes (via _reroute!)
@testset body in web/src/test/runtests.jlYes
@testset via ../ path outside the pkg treeNo — source-text cache mismatch
@generated function helperNo — world-age frozen
Revise.includet(SubModule, file)Breaks all tracking — never use
const value changeNo — APPDATA must never be reassigned

Gotcha: renaming the root @htmx struct ​

Revise does not re-run __init__(). Renaming the root struct (e.g. AppContext → AppRoutes) means route!(AppRoutes()) never fires, so no routes register. Every route returns the framework's error article with HTTP 200 — easy to miss if you only check status codes.

Triage: curl -s ...?plain and check the body, not just the status.

Fix without restart: temporarily add a bare top-level route!(AppRoutes()) line in the module file. Revise evaluates new top-level expressions, so routes register under the new root. Remove the line once verified — subsequent reloads go through _reroute!.

HTML building with h ​

h (re-exported from HTMX.jl) builds a Cobweb Node tree.

julia
h.div(; class="container")(              # keyword attributes, positional children
    h.h1("Title"),
    h.p(; hx_get="/data", hx_target="#result")("Load"),
)
  • Underscores in attribute names become hyphens (hx_get → hx-get).

  • htmx(content; pico_version, htmx_version, hyperscript_version, extra_head, feedback) wraps content in a full HTML page with CDN tags.

  • @__str embeds inline Hyperscript: h.button(__"on click toggle .hidden on #menu")("Toggle").

  • HTMX inherits hx-swap, hx-target, hx-trigger from ancestors. Use hx-disinherit="hx-swap ..." on a container when its children should not inherit.

HTMX headers ​

Inspect the request with is_htmx(req), hx_target(req), hx_trigger(req), hx_current_url(req), hx_boosted(req), hx_prompt(req).

Attach response headers with hx_response:

julia
hx_response(content;
    trigger     = "itemSaved",    # HX-Trigger — fire a client event
    push_url    = "/new/path",    # HX-Push-Url
    retarget    = "#other",       # HX-Retarget
    reswap      = "outerHTML",    # HX-Reswap
    redirect    = "/login",       # HX-Redirect
    refresh     = true,           # HX-Refresh
    location    = "/dashboard",   # HX-Location
)

Out-of-band swaps ​

to_response accepts content => "id" pairs — wraps content in a <div> with hx-swap-oob="true" and the given id:

julia
to_response([main_body, sidebar_html => "sidebar"])

Form helpers ​

All exported from HTMXObjects.

Inputs: linput, sinput, sinput_custom, soption, rinput, ninput, cinput, tinput, radio_group, ainput.

Forms: get_form, post_form, hidden_inputs.

Tables: render_table(table; id, sortable, download, download_filename, caption, cell, class, kwargs...) — any Tables.jl-compatible table as an h.table with click-to-sort headers and CSV download. Multi-table pages work automatically (th.closest('table')).

Widgets: status_badge(state::Symbol), nav_sidebar(items), htmx_tabset(items; active, target), lazy(url; tag, swap, kwargs...), tabset, request_feedback (pulsating outline while in-flight, teal flash on success, coral on error — included by default in htmx()).

Conditional visibility: sinput(...; show_when=(field, op, value)) with show_when_script() once per page. Ops: ==, !=, startswith, endswith.

Captions: CaptionSpec(; title, short, long), render_caption(spec), with_caption(spec, content).

See the docstrings for full signatures.

Git-backed inline editors ​

Three primitives for editable content backed by a git repository:

  • GitRepo — @dynamicstruct owning a working directory. Auto-git inits on first access. API: blob_sha, read_blob, list_commits, write_file!, locked_update!, editor(relpath).

  • editor_form(; id, post_url, content, version, ...) — HTML form with Save/Cancel buttons and Escape-to-cancel.

  • EditorRoutes — @htmx mountable via @include under a parent that exposes editor (GitRepo.editor(relpath)) and container_id::String locals. Ships with @get form, @post save, @get history, @post restore.

Parent @param values auto-forward through edit/save/cancel/history/ restore via query_url(path, __parent__), so a parameterised parent (@param name::String picking a file) round-trips with no extra wiring.

For partial-file edits (e.g. one key of a multi-key YAML), use repo.locked_update!(relpath; message) do current ... end instead of write_file! — the former serialises under the per-repo lock and composes with write_file!, avoiding the read-then-write race that optimistic concurrency would surface as a spurious conflict.

See HTMXObjects/web/src/git_editor_demo.jl for a worked example.

serve and threading ​

julia
serve(; host="127.0.0.1", port=8080, async=false, parallel=false, revise=nothing, kwargs...)
  • parallel=false → single-threaded (default).

  • parallel=true → Oxygen's default thread pool.

  • parallel=:interactive → :interactive threadpool, leaving :default free for heavy computation. Launch Julia with e.g. julia -t 8,4 for 8 computation threads + 4 request-handling threads.

  • revise=:lazy is the usual dev setting.

What belongs where — summary ​

On a @dynamicstruct (data, backend):

  • Collections (items::Vector{...}), caches, filesystem/DB handles.

  • Expensive computations (@cached).

  • Shared parsers, stores, resolvers.

  • Anything that outlives a single request.

On a @htmx (requests, UI):

  • HTML fragments and page layouts (sibling references make these ergonomic).

  • Routes (@get/@post/etc.).

  • @param declarations.

  • Action handlers that mutate state on AppData and return updated UI.

Outside both:

  • Pure utilities that don't reference any property. Prefer promoting them to a property on the relevant struct anyway.

  • Types (struct ExampleEntry, PersistentSet, ...).

The web module should contain only APPDATA, struct definitions, and __init__. No top-level functions, no module-level const besides APPDATA, no custom Oxygen route handlers.

Anti-patterns (observed in the wild) ​

  • Manual formdata(req) / queryparams(req) in route bodies. Use kwargs or @param — type coercion, defaults, and multi-value handling are free.

  • __page__(...) inside a route body (double layout). Return bare content.

  • is_htmx(__req__) ? fragment : full dual-path. The pipeline does this.

  • if wants_markdown(__req__) ... else ... end branches. Ditto.

  • Plain Dict{K,V}() at module top level. Oxygen is concurrent.

  • const FOO = ... besides APPDATA.

  • Standalone function foo(...) ... end at module top level. Promote to a property on the owning struct. Plain function blocks inside a @htmx/@dynamicstruct body cause a parse error (the macro parses the body as property definitions).

  • Revise.includet(SubModule, file) — clobbers Revise's pkgdata and breaks all tracking.

  • @enum X A B C for form-driven data. Julia's @enum has no parse method. Use String or Symbol.

  • Custom Oxygen route handlers alongside route!. Extend route! instead.

Further reading ​

API reference ​

HTMXObjects.ERROR_DIR Constant
julia
ERROR_DIR

Directory where HTMXObjects writes per-error log files. Initialized in __init__ from the HTMXO_ERROR_DIR environment variable, falling back to joinpath(tempdir(), "htmxo_errors"). Can be reassigned at runtime.

source
HTMXObjects._FORM_KEYS Constant
julia
_form(method, url, children...; label="Submit", btn_class="btn", confirm="", hx_target="", hx_swap="", hx_include="", form_class="", kwargs...)

Shared implementation for get_form and post_form. method is :hx_get or :hx_post. Extra keyword arguments become hidden <input> fields. Positional children are inserted before the submit button.

source
HTMXObjects.CaptionSpec Type
julia
CaptionSpec(; title, short="", long=nothing)

Caption metadata for a figure or table. title is a short bold heading, short is a one-line summary (typically built from live state so every parameter is visible), and long is an optional longer description shown behind a <details> toggle inside the <figcaption>.

long may be a String or any HTMX Node (e.g. h.div(h.p(...), h.p(...))).

source
HTMXObjects.EditorRoutes Type
julia
EditorRoutes

Git-backed inline editor @htmx struct. Mount as an @include under a parent that exposes editor (a GitRepo per-file handle from editor(relpath)) and container_id::String (HTML id of the wrapper) as locals. All URLs forward the parent's @param values via query_url(path, __parent__), so a parameterised parent (@param name::String picking which file to edit) survives edit/save/cancel/history/restore round-trips with no extra wiring.

Routes

  • @get form — renders the editor form via editor_form.

  • @post save(; content, version) — optimistic-concurrency write. On conflict, re-renders the form with a "content changed" banner.

  • @get history — lists prior versions with per-version Restore buttons.

  • @post restore(; sha) — reverts the file to an earlier blob.

Optional struct fields

  • input::Symbol = :textarea (or :text)

  • rows::Int = 15

  • placeholder::String = ""

  • label = nothing

See the section_routes demo in HTMXObjects/web/src/git_editor_demo.jl.

source
HTMXObjects.Gallery Type
julia
Gallery(gallery_dir)

Walks gallery_dir recursively and builds one GalleryItem per .jl file. Reads section titles from _section.md files in each subdirectory (first non-empty line, or title: … key/value).

Items are sorted by (section, filename) for stable gallery order.

find_item(g, id) returns the item with the given id, or nothing. section_items(g, section) returns the items belonging to that section.

source
HTMXObjects.GalleryItem Type
julia
GalleryItem(path)

Single gallery entry parsed from a Julia source file at path. Reads the metadata header (# key: value lines) and captures the rest of the file as code_string. The body is Base.include-able by the app so it can be eval'd to produce a value (Vega-Lite spec, Stan model, formula text, …) the app's renderer turns into HTML.

Standard properties:

  • path — absolute file path.

  • id — defaults to filename without extension; override via # id: ….

  • title — defaults to id; override via # title: ….

  • description — # description: … or empty.

  • section — # section: … or the parent directory's basename.

  • metadata — full parsed header dict.

  • code_string — file body with the header stripped.

Base.show(io, MIME"text/markdown"(), item) produces the agent-friendly view: ## title \n *description* \n``.

source
HTMXObjects.HtmlOnly Type
julia
html_only(content)

Wrap content that should only appear in HTML responses. In markdown mode, this produces an empty string. Use for interactive elements, styling, badges, etc. that have no meaningful text representation.

source
HTMXObjects.MIMEResponse Type
julia
MIMEResponse(content_type, body)

Small wrapper for routes that return non-HTML content (JS, JSON, CSS, SVG, plain text, etc.) without hand-rolling an HTTP.Response. _resolve_response converts it to HTTP.Response(200, ["Content-Type" => content_type]; body). The recording flow's _save_typed_response then dispatches on Content-Type to pick the right file extension (.js for application/javascript, .json for application/json, etc.). One line, fully recordable, MIME-aware.

julia
@get aov_runtime_js = MIMEResponse("application/javascript",
    strip_script(sprint(show, MIME"text/html"(), vega_runtime())))
source
HTMXObjects.MarkdownOnly Type
julia
markdown_only(text)

Wrap text that should only appear in markdown responses. In HTML mode, this is invisible (renders as empty string). Use for structured markdown output (code blocks with markers, custom formatting) that has no HTML equivalent.

source
HTMXObjects.RecordingRoutes Type
julia
@include record_gallery = RecordingRoutes(;
    app_type=AppContext,
    paths=String["/", "/page", "/plot/foo"],
    record_dir="docs/src/public/live-app",
    record_base="/MyPkg.jl/dev/live-app",
)

Mountable routes for the docs-build flow. Provides one route at the mount prefix:

GET <prefix>/ — fires the recording IP. ?force=true invalidates the cache and re-records. With Treebars loaded, returns a live polling_fetchindex progress tree until done; without it, blocks until done and returns the summary article.

source
HTMXObjects.SharedOpsRoutes Type
julia
SharedOpsRoutes()

Reusable ops sub-bundle providing the three standard endpoints every web app needs: /reset (clear in-memory caches), /revise/errors (display queued Revise errors), /revise/now (force-trigger Revise).

Mount as

julia
@include ops = SharedOpsRoutes()

inside the root @htmx struct. The bundle assumes the host app has __appdata__ wired on the root (for clear_mem_caches!) and Revise loaded in Main (for the /revise/* routes).

source
HTMXObjects.StructureRoutes Type
julia
StructureRoutes(; root::Type)

Live browser for the DynamicObjects type tree rooted at root. Mount inside an @htmx struct with @include structure = StructureRoutes(; root=AppContext) to expose:

  • /structure — tree of every type reachable from root, with per-type lint badges (✗N errors, ⚠N warns) from DynamicObjects.analyze_structure.

  • /structure/type?name=T — DynamicObjects.structure(T) rendering with inline lint hints, plus a per-property links list to source/callers.

  • /structure/source?name=T&prop=p — the property's RHS expression (lnn-stripped), signature, and dependson set.

  • /structure/callers?name=T&prop=p — every site that calls a property named p (matched by callee NAME across the whole tree, not by resolved owner type — cross-type collisions show up too).

  • /structure/lints[?severity=error|warn] — aggregated lint dump grouped by type.

Useful for debugging refactors, finding misplaced IPs, and locating single-callsite helpers without grepping. Cheap to mount — recomputes lints on each request.

source
HTMXObjects.TestRoutes Type
julia
TestRoutes(; test_module, prefix="/tests")

Mountable @htmx route bundle that wires up a test-runner UI for the @testsets defined in test_module. Mount it under any @htmx struct via:

julia
@include tests = TestRoutes(; test_module=@__MODULE__)

Provides:

  • @get index — render the cached test list (see test_list)

  • @post run(name) — re-run a single named @testset (see test_run!)

  • @post run_all — run every test (see test_run_all!)

  • @post run_failed — re-run only previously-failed tests

  • @post run_missing — run tests with no cached result

  • @post run_batch — run a comma-separated batch of named tests

  • @post clear_cache — discard all cached results

The underlying test_* functions are only available when both Test and TestModules are loaded (they're provided by the package's TestModulesExt extension).

source
HTMXObjects.Long Method
julia
Long(x)

Convert a symbol/string to a human-readable label by replacing underscores with spaces. Add methods for custom labels: Long(::Val{:pk}) = "Pharmacokinetics".

source
HTMXObjects._convert_include_to_struct! Method
julia
_convert_include_to_struct!(struct_expr)

Pre-process @include lines:

  • @include prop = begin...end is rewritten to prop = struct _Include_prop ... end so DO's inline-struct machinery wires __parent__=__self__ automatically.

  • @include prop = ExternalStruct(; ...) keeps the external call but its kwargs are augmented with __parent__=__self__, __prefix__=__self__.__prefix__ * "/prop" so the sub-struct receives the request/appdata chain (via __parent__) and the correct mount prefix at construction time. User-provided values for these kwargs win; we only fill them in if absent.

source
HTMXObjects._convert_params! Method
julia
_convert_params!(struct_expr) -> Vector{Symbol}

Find @param name::T = default lines in a @htmx struct body and rewrite them to plain derived-property assignments that call _extract_param(_req_of(__self__), ...) at property-access time. Supports:

julia
@param vessels::Vector{String} = ["Tablet-20"]
@param fit_key::String                             # required, errors on miss
@param note = "hi"                                 # untyped, with default
@param begin
    vessels::Vector{String} = ["Tablet-20"]
    n_bootstrap::String     = "10"
end
@param (; fit_key, top_chains, max_draws) = __parent__   # delegate to parent

The delegation form registers each listed name as a param on this struct (so query_url(__self__) serializes them) and resolves their value at access time via source.<name> — typically __parent__.<name> — rather than extracting from the request directly. See _rewrite_param_delegation.

Returns the ordered list of declared param names for later _param_names emission.

source
HTMXObjects._default_error_render Method
julia
_default_error_render(uid, path)

Default rendering for a caught error — a small article pointing at the recorded error id. Override by defining __error__ on the route's enclosing struct (or, to disable catching entirely, set __error__ = rethrow).

In dev (Revise loaded), include the full log path so the article matches the @error line on stderr (HTMXObjects caught an error: <path>) — the same string is already in the server log, including it here lets the developer / agent open the file in one step from the in-browser article. In prod (Revise not loaded), keep just the uid.

source
HTMXObjects._disable_for_static Method
julia
_disable_for_static(val) -> val

Walk a Node tree and disable elements that won't work on a static server:

  • Strip hx-post, hx-put, hx-patch, hx-delete attributes

  • Strip hx-get attributes whose URL contains ? (query-param routes)

  • Strip hx-get attributes pointing to kwargs routes (from _static_kwargs_paths)

  • Mark affected elements with data-static-disabled and disabled

Non-Node values pass through unchanged.

source
HTMXObjects._extract_param Function
julia
_extract_param(req, name, T, default=_NO_DEFAULT) -> value

Extract a single typed parameter from an HTTP request, mirroring the behavior of @get/@post kwarg extraction. Looks up name in the method-appropriate source (queryparams for GET/DELETE, formdata for POST/PUT/PATCH with a queryparams fallback), converts via _convert_param(value, T), and returns default when the key is absent or empty. Throws KeyError(name) if no default was supplied.

T may be nothing for untyped params (raw String/Vector{String}).

source
HTMXObjects._has_page Method
julia
_has_page(obj) -> Bool

True if obj defines either __page__ or the legacy page property.

source
HTMXObjects._inject_dunder_props! Method
julia
_inject_dunder_props!(struct_expr)

Ensure that @htmx struct bodies always declare __parent__, __prefix__, __req__, __appdata__, and __route__ as properties so route bodies can reference them and so the __parent__ chain threads request context, appdata, the mount prefix, and the per-request route URL down through @included sub-structs.

Defaults:

  • __parent__ = nothing — set by @include desugar to point at the enclosing struct.

  • __prefix__ = "" — root-level mount path (no leading /); @include builds parent.__prefix__ * "/childname" for each nested sub-struct.

  • __req__ / __appdata__ — fall through __parent__ if present, else nothing. __req__ is supplied by HTMXO at request-handler construction. __appdata__ is supplied by the user — the conventional pattern is to override the default inside the root @htmx struct body (__appdata__ = APP_DATA with const APP_DATA = AppData() at module scope) so the singleton is part of the struct's definition, not of route!'s API.

  • __route__ = "" — defaults to empty; _register_route_handler sets it to req.target (with query string stripped) when constructing the struct for a real request, so route bodies can write hx_get=__route__ / href=__route__ instead of recomputing __self__/"name/$id". For non-request constructions (e.g., route! at module init) it stays "".

The legacy short names req / appdata are no longer recognised.

source
HTMXObjects._inject_include_prefix! Method
julia
_inject_include_prefix!(call_expr, prop_name; index_params=Symbol[])

Mutate the kwargs of SomeStruct(args...; ...) so that __prefix__=__self__.__prefix__ * "/prop_name"[ * "/" * string(arg)…] is present (without overriding any user-provided value). For an indexed include (@include prop(x, y) = External(x, y)), the index_params are interpolated into the prefix at runtime so each invocation of the property gets the URL segment for its current arg values.

__parent__ and __status__ are handled by DynamicObjects' @include processing.

source
HTMXObjects._inject_static_style Method
julia
_inject_static_style(val)

If val is a full HTML page (contains a <head>), inject the disabled-element style block.

source
HTMXObjects._inject_verb_in_route_lhs! Method
julia
_inject_verb_in_route_lhs!(struct_expr)

For every @get/@post/@put/@patch/@delete/@ws macrocall in the struct body, rewrite the call-form LHS to inject ::HTMXObjects.Verb{V} as the first positional argument, where V is the verb short symbol (:GET, :POST, :WEBSOCKET, …). After this rewrite, DynamicObjects' @dynamicstruct sees:

julia
@get foo(x::Int) = body          # source
foo(::HTMXObjects.Verb{:GET}, x::Int) = body   # after rewrite (LHS)

and emits compute_property(::T, ::Val{:foo}, ::Verb{:GET}, x::Int) = body. The route registrar threads Verb{V}() into prop(Verb{V}(), idx_vals…; kw…) at request-handling time, so verb dispatch is purely a method-table lookup.

Bare-form LHS is impossible here — _extract_route_info already rejected it. The injection is idempotent guarded: if the first positional arg already has a ::Verb{…} annotation (e.g. when re-running on a Revise re-eval), this is a no-op.

source
HTMXObjects._interactive_stream_handler Method
julia
_interactive_stream_handler(middleware::Function)

Like Oxygen's stream_handler + parallel_stream_handler, but spawns each request on the :interactive threadpool instead of :default.

source
HTMXObjects._lookup_param Method
julia
_lookup_param(src, fallback, name, T) -> value or _NO_DEFAULT

Low-level parameter lookup shared by _extract_param and the route-macro's generated _extract_args methods. Given a precomputed primary source dict and optional fallback dict (e.g. queryparams for POST), look up name, apply _convert_param(value, T) when present, or return the _NO_DEFAULT sentinel when the key is absent/empty so callers can decide between default or error.

source
HTMXObjects._page_wrapper Method
julia
_page_wrapper(obj) -> wrapper or nothing

Look up the "page" wrapper on obj, preferring the framework-managed __page__ name over the legacy page name. Returns nothing if neither is defined. The legacy page name is deprecated — the @htmx macro emits a warning at expansion time when it sees a page property (see _warn_legacy_page_name!).

source
HTMXObjects._print_req_meta Method
julia
_record_error(err, bt, req) -> (uid, path)

Write a detailed error report to joinpath(ERROR_DIR[], "<uid>.log") and return both the short uid and the full file path. Also emits an @error log entry that includes the full path so the recorded file is one click away in the terminal/log viewer.

source
HTMXObjects._register_route_handler Method
julia
_register_route_handler(RootT, LeafT, chain, method, name, path, n_params, record_dir; root_prefix="")

Single chokepoint for HTTP route handler registration. Routes are uniform since bare-form LHS is rejected at macro time — every route is an indexable property dispatching on ::Verb{V} as the first positional arg. The handler:

  1. Constructs the root struct.

  2. Walks the include chain if any.

  3. Calls _extract_args(LeafT, Val(name), Verb{V}(), req, base, n_params) to parse URL/query/form params.

  4. Invokes prop(Verb{V}(), idx_vals…; kw…) where prop = getproperty(leaf, name) is the IP wrapper.

Verb{V} is the singleton encoded from Symbol(method) at registration time ("GET" → Verb{:GET}).

WebSocket handlers do not go through this path — they have a different signature (ws instead of req) and skip the HTTP error pipeline.

source
HTMXObjects._req_of Method
julia
_req_of(obj) -> HTTP.Request or nothing

Look up the framework-managed request on obj. @htmx always injects __req__ (defaulting to nothing) when not user-declared.

source
HTMXObjects._resolve_response Method
julia
_resolve_response(obj, req, val; record_dir=nothing, save_path=nothing)

Convert a route handler's return value to an HTTP response, automatically handling markdown, HTMX fragment, and full-page modes.

Convention-based properties on obj:

  • __page__(content) — wraps fragment in full page (for direct browser requests). Legacy name page still works with a deprecation warning.

  • to_markdown(val) — custom markdown serializer

If the route returns an HTTP.Response directly, it passes through unchanged.

source
HTMXObjects._resolve_response_nested Method

Like _resolve_response, but applies nested page wrappers (innermost first, then outward).

source
HTMXObjects._save_typed_response Method
julia
_save_typed_response(record_dir, url_path, response)

Save an HTTP.Response body using a file extension derived from its Content-Type header. Used by the recording flow to capture routes that hand-roll their response (/aov_runtime_js → .js, /spec/<id> → .json, etc.) so the deployed static site serves them with the right MIME.

source
HTMXObjects._warn_hardcoded_url_in_attrs! Method
julia
_warn_hardcoded_url_in_attrs!(struct_expr)

Walk the struct body looking for href="/foo" / hx_get="/foo" / src="/foo" etc. — root-absolute URL string literals on URL-bearing attributes. Such URLs bypass __prefix__ (so they break under any non-root mount, base-path deploy, or record! target) and bypass __route__ (so updating a route's path requires hunting all literal references). Suggest __self__/"…" (cross-route) or __route__ (self-link) instead.

Warning, not error: the hardcoded form still works for root-mounted local dev. Apps adopt the prefix-aware form gradually.

source
HTMXObjects._warn_redundant_req_decl! Method
julia
_warn_redundant_req_decl!(struct_expr)

Warn when the user explicitly writes __req__ = nothing. _inject_dunder_props! adds this line automatically when no __req__ is declared, so an explicit declaration is redundant noise.

source
HTMXObjects._wrap_ws_bodies! Method
julia
_wrap_ws_bodies!(struct_expr)

Pre-process the struct body: for any @ws property, wrap the RHS in (__ws__) -> RHS. This lets users write @ws feed = begin ... __ws__ ... end and have __ws__ available as the WebSocket variable, while DynamicObjects stores a callable (__ws__) -> body.

source
HTMXObjects.ainput Method
julia
ainput(nv; kwargs...)

Smart h.input wrapper that accepts a NamedTuple or String. A NamedTuple like (; fit_key) extracts name and value automatically:

julia
ainput((; fit_key); type="hidden")
# equivalent to: h.input(; name="fit_key", value=fit_key, type="hidden")
source
HTMXObjects.app_layout Method
julia
app_layout(sidebar, content; content_id="content")

Two-column app shell — sidebar <aside> next to a main content column. Inspired by Pico's docs layout (CSS Grid with named areas, sticky inner nav, stacks on mobile <1024px). sidebar is rendered as-is (typically nav_sidebar); content is wrapped in <div class="htmxo-app-content" id=content_id> so HTMX swaps targeting #content work out of the box.

Sidebar width defaults to 12rem via the --htmxo-sidebar-width CSS variable; apps can override it in their own scoped style:

julia
.htmxo-app-layout { --htmxo-sidebar-width: 10rem; }
julia
__page__(content) = htmx(
    app_layout(
        nav_sidebar(["Table" => "/", "Gallery" => "/gallery"]),
        content,
    );
    pico_version="2",
)
source
HTMXObjects.caption_style Method
julia
caption_style()

Return a <style> node with default CSS for render_caption / with_caption: flex layout for the caption header (title left, actions right) and small spacing for the <details> body. Include once per page.

source
HTMXObjects.cinput Method
julia
cinput(nv; label=Long(nv), checked=_avalue(nv, false), switch=false, show_when=nothing, kwargs...)

A labeled checkbox <input type="checkbox">. Set switch=true to use Pico CSS's toggle-switch style (role="switch"). Extra kwargs go on the <input>.

source
HTMXObjects.create_app Method
julia
create_app(path; port=8000, module_name=nothing, setup=true)

Generate a new HTMXObjects app at path with the standard package + app structure:

julia
path/
├── Project.toml
├── src/<ModuleName>.jl
└── app/
    ├── Project.toml
    └── main.jl

module_name defaults to the directory basename (with .jl stripped). If setup=true (default), runs Pkg.develop and Pkg.instantiate to make the app immediately runnable. Then start with:

julia
cd path && julia -i --project=app app/main.jl
source
HTMXObjects.default_gallery_card Method
julia
default_gallery_card(item::GalleryItem)

Default card layout: title heading + description (always visible) + inline source code block. No collapsible details — important info is visible directly. Apps wrap this with their own card_renderer = item -> h.article(default_gallery_card(item), my_render(item)) to add Vega-Lite plots, PPC plots, etc.

source
HTMXObjects.download_table_js Method
julia
download_table_js()

Return an h.script(...) node containing the downloadTableCsv JavaScript function used by render_table(...; download=true). Include this once per page (e.g. in extra_head).

The JS reads the closest <table> relative to the clicked button, serializes the visible header cells and body rows to CSV (escaping ", ,, and newlines), and triggers a Blob download. Sort indicator arrows (▲/▼) added by sortable_table_js() are stripped from header text.

source
HTMXObjects.dynamicfiles Function
julia
dynamicfiles(folder, mountdir="static"; headers=[], loadfile=nothing)

Serve dynamic files from folder at the URL prefix mountdir. Files are re-read from disk on each request (no caching).

source
HTMXObjects.e Method
julia
e

Error-tagged HTML builder. Works exactly like h, but adds data-error="true" to every element. Elements with this attribute survive the ?error filter.

julia
e.div(id="foo")("content")  # <div id="foo" data-error="true">content</div>
e.span("error!")             # <span data-error="true">error!</span>
source
HTMXObjects.editor_form Method
julia
editor_form(; id, post_url, content="", version="", ...) -> Node

Inline edit form for text content. Renders a <textarea> (default) or text <input> with Save/Cancel buttons and Escape-to-cancel. On submit, POSTs to post_url with form fields content (or field_name) and version; the response is swapped into #target_id via hx-swap=$swap.

version is a hidden conflict token — echoed back by the save handler so it can detect concurrent edits. Empty string is fine for apps that don't check.

Cancel:

  • cancel_url → Cancel issues hx-get to that URL.

  • cancel_onclick → Cancel runs that JS (e.g. "this.closest('#$id').remove()").

Exactly one must be given.

Arguments

  • id — container/form element id.

  • tag — outer tag (default h.div).

  • class — outer element class.

  • post_url — URL for Save.

  • target_id — hx-target for Save response (default id).

  • swap — hx-swap value (default "outerHTML").

  • content — initial content.

  • version — hidden version/hash token for conflict detection.

  • field_name — form field name for the edited text (default "content").

  • input — :textarea or :text.

  • rows — textarea rows.

  • placeholder — input placeholder.

  • label — optional label rendered above the editor.

  • cancel_url — URL for Cancel (hx-get).

  • cancel_onclick — inline JS for Cancel.

source
HTMXObjects.editor_styles Method
julia
editor_styles()

CSS for editor_form: monospace <textarea>/<input> editor body (.htmxo-editor-input) and the Save/Cancel button row (.htmxo-editor-actions). Scoped class names, no global selectors. Auto-included by htmx; inject manually via extra_head if you build your own <head>.

source
HTMXObjects.escape_html Method
julia
escape_html(s) -> String

Escape &, ", ', <, > for safe interpolation into element text or attribute values. Thin re-export of Cobweb.escape so callers don't need to reach through HTMX.jl's transitive dependency.

Note: h.code(s) and friends do not auto-escape text content — call escape_html explicitly when interpolating untrusted or markup-bearing strings.

source
HTMXObjects.filter_errors Method
julia
filter_errors(val)

Walk a Node tree and keep only nodes that have data-error="true" or contain a descendant with data-error="true". Non-error branches are removed. Non-Node values pass through unchanged.

source
HTMXObjects.fmt_bytes Method
julia
fmt_bytes(n) -> String

Format a byte count n with appropriate binary units (B, KB, MB, GB, TB).

source
HTMXObjects.fmt_number Method
julia
fmt_number(x; sigdigits=2) -> String

Format a number with SI suffixes (K, M, B, T) or scientific notation for very small values.

source
HTMXObjects.fmt_time Method
julia
fmt_time(t) -> String

Format a time duration t (in seconds) with appropriate SI units. Returns e.g. "1.23ns", "456μs", "78.9ms", "1.23s", "5.0min", "2.5hr".

source
HTMXObjects.gallery_controls_script Method
julia
gallery_controls_script() -> String

The inline JS that wires up the gallery toolbar. Returned as a string so it can be embedded as h.script(gallery_controls_script()). Idempotent: running it multiple times against the same root is harmless (each run re-binds listeners on the same elements; classes are re-derived from the current input/select state).

source
HTMXObjects.gallery_grid Method
julia
gallery_grid(items; section_titles=Dict(), card_renderer=default_gallery_card,
                    controls=true, search=true, pagination=true,
                    page_size=25, page_sizes=(10, 25, 50, 100, 0),
                    search_placeholder="Search (regex; literal fallback)…")

Render a single-column grid of card_renderer(item) cards, grouped by section. Sections appear in directory-traversal order (preserved by Gallery.items). The section heading uses section_titles[section_id] if present, falling back to the bare section id (typically the directory basename).

When controls=true (default), the result is wrapped in a .htmxo-gallery-root that also carries a sticky toolbar with a search box and pagination controls. The toolbar is driven by an inline controller script that toggles is-search-hidden / is-page-hidden classes on cards (and is-empty on sections whose visible-card count drops to zero). The controller works identically in:

  • Live HTMXObjects pages (browser executes the inline <script> as part of normal page load).

  • Recorded fragments embedded into VitePress docs (HTMX innerHTML swaps don't auto-execute scripts, but setupHtmxoEmbed from htmxo-embed.ts re-executes inline scripts inside .htmxo-embed after every swap so the controller wires up there too).

URL state: the search query mirrors ?q=, the current page mirrors ?page=, and the page size mirrors ?ps= — so a deep-link to a filtered/paginated view round-trips.

Pass controls=false to opt out and recover the original bare .htmxo-gallery <div> (used by apps that want to compose their own toolbar).

Standard CSS classes: htmxo-gallery-root, htmxo-gallery-toolbar, htmxo-gallery-search, htmxo-gallery-pagination, htmxo-gallery, htmxo-gallery-section, htmxo-gallery-section-heading, htmxo-gallery-card, htmxo-gallery-card-title, htmxo-gallery-card-description, htmxo-gallery-card-code.

source
HTMXObjects.gallery_toolbar Method
julia
gallery_toolbar(; search=true, pagination=true,
                   page_size=25, page_sizes=(10, 25, 50, 100, 0),
                   search_placeholder="Search…")

Build the gallery toolbar fragment used by gallery_grid(; controls=true). Exposed standalone so apps that compose their own gallery wrapper can reuse the same controls. 0 in page_sizes is shown as "all" — the controller reads parseInt(value) and treats <=0 as "no pagination".

source
HTMXObjects.get_form Method
julia
get_form(url, children...; kwargs...)

Generate a complete inline GET form. Same API as post_form but uses hx-get instead of hx-post.

julia
get_form("/analysis/dose_response",
    sinput((; sources), source_options; multiple=true),
    sinput((; outcomes), outcome_options; multiple=true);
    hx_target="#results", hx_swap="innerHTML",
    fit_key, top_chains=string(top_chains),
)
source
HTMXObjects.hidden_inputs Method
julia
hidden_inputs(; kwargs...) -> Vector{Node}

Generate hidden <input> elements for each keyword argument. Splat into a form to pass parameters to @post routes via formdata:

julia
h.form(; hx_post="/foo", hx_target="#list", hx_swap="innerHTML")(
    hidden_inputs(; script=path, worktree=wt)...,
    h.button(; class="btn", type="submit")("Go"),
)

Vector values produce repeated inputs (one <input> per element).

source
HTMXObjects.html_escape Method
julia
html_escape(s) -> String

Escape &, <, > in s for safe direct interpolation into raw HTML strings. Use only when bypassing HTMX.jl's automatic escaping (e.g. when building HTML via replace(_, => "<a href=...>...") for a <pre> block).

For attribute values or text that may contain quotes / apostrophes, use escape_html (which delegates to Cobweb.escape) for the full 5-character escape set.

source
HTMXObjects.htmx Method
julia
htmx(body...; htmx_version="2.0.8", hyperscript_version="0.9.14", pico_version=nothing, feedback=true, extra_head=())

Generate a full HTML page with HTMX and optionally Hyperscript/PicoCSS loaded from CDN. Pass nothing to any version kwarg to skip that library. Set feedback=false to disable automatic request feedback (pulsating borders, success/error flash).

source
HTMXObjects.htmx_or Method
julia
htmx_or(full_page_fn, req, fragment)

Return fragment directly for HTMX requests; call full_page_fn() and wrap its result for direct browser navigation. Typical usage:

julia
htmx_or(req, fragment) do
    htmx(h.main(search_input(q), fragment))
end
source
HTMXObjects.htmx_tabset Method
julia
htmx_tabset(items; active=nothing, target="#content", ...)

HTMX-driven tab row: each tab click fetches content from the server. items is a collection of "Label" => url pairs. tab_attrs(label) returns extra per-tab named-tuple attributes (e.g. hx_include).

source
HTMXObjects.htmxo_breadcrumb Method
julia
htmxo_breadcrumb(items; target="#content")

Render a Pico-styled breadcrumb <nav>. Each item is a tuple (label, frag_url, push_url). When frag_url is nothing, the segment renders as plain text (the current page). Otherwise it's an <a> that issues hx-get=frag_url into target and updates the URL bar via hx-push-url=push_url.

julia
htmxo_breadcrumb([
    ("Home",     "/",          "/"),
    ("Projects", "/projects",  "/projects"),
    ("Detail",   nothing,      nothing),
])
source
HTMXObjects.htmxo_embed_html Method
julia
htmxo_embed_html(; base, swap="innerHTML", placeholder="Loading…", class_="htmxo-embed")

Build the placeholder <div> that setupHtmxoEmbed resolves at runtime. base is the path suffix below the deploy abspath (e.g. "live-aov/") — in dev it resolves to "/<base>" (Vite proxy), in prod to "<deploy-abspath>/<base>" (committed recordings).

Use as a Cobweb Node from a Julia-driven page, or stringify and embed in markdown:

julia
println(io, htmxo_embed_html(; base="live-aov/"))
source
HTMXObjects.htmxo_gallery_styles Method
julia
htmxo_gallery_styles()

<style> block with the canonical gallery layout: single-column, flat (no card frame), proper heading hierarchy, themed via --htmxo-* tokens. Include via extra_head in htmx(...) so all HTMXO apps present galleries the same way.

Source of truth is HTMXObjects/assets/vitepress/htmxo-gallery.css — same file is imported by htmxo-embed.ts so the docs-embedded fragment matches.

source
HTMXObjects.htmxo_syntax_head Method
julia
htmxo_syntax_head(; languages=("julia", "stan"))

Head elements (script tags + theme-aware <style>) for client-side PrismJS syntax highlighting that flips with the host theme (VitePress html.dark, Pico data-theme="dark"). Any <code class="language-…"> that PrismJS recognizes will be tokenized; the token coloring uses --htmxo-syntax-* CSS variables which adapt automatically.

Use as a splat in extra_head= of htmx(...):

julia
extra_head=(htmxo_gallery_styles(), htmxo_syntax_head()...)

The same token CSS gets shipped to docs themes via vitepress_theme_install (alongside htmxo-embed.ts/htmxo-gallery.css) so embedded fragments inherit the theme-aware coloring without needing the script tags from the fragment to execute (which they wouldn't, since innerHTML swaps don't run scripts).

source
HTMXObjects.htmxo_theme Method
julia
htmxo_theme()

<style> block with HTMXO's framework-agnostic CSS theme variables.

Defines the --htmxo-* custom properties that every HTMXO-emitted style consumes (request_feedback_style, tabset_styles, status_badge, …). Defaults are sensible standalone (the values that have shipped historically), and are declared at zero specificity (:where(:root)) inside @layer htmxo so any host's :root { --htmxo-...: ... } override wins automatically — no specificity wars, no host detection in component code. See pico_bridge and vitepress_bridge for one-line adapters.

source
HTMXObjects.htmxo_utility_styles Method
julia
htmxo_utility_styles()

<style> block with HTMXO's small utility-class set. Replaces inline style="..." on common patterns: visibility, cursor, flex/grid layout, spacing, typography, and theme-aware text colors. All classes are scoped under @layer htmxo so host stylesheets always win on conflict.

Class prefixes:

  • u- general utilities (display, layout, typography, spacing)

  • u-text-* semantic text colors mapped to --htmxo-* tokens

  • u-mt-* etc. margin / padding scale: 0, 1 (0.25rem), 2 (0.5rem), 3 (0.75rem), 4 (1rem), 5 (1.5rem), 6 (2rem)

source
HTMXObjects.hx_boosted Method

Return true if the request was boosted by HTMX (HX-Boosted: true header).

source
HTMXObjects.hx_current_url Method

Return the HX-Current-URL header value (the browser URL at the time of the request), or "".

source
HTMXObjects.hx_link Method
julia
hx_link(url; kwargs...)

Create an h.a with both href and hx-get set to url. Extra kwargs (hx_target, hx_swap, class, etc.) are forwarded to h.a.

source
HTMXObjects.hx_prompt Method

Return the HX-Prompt header value (the user's response to hx-prompt), or "".

source
HTMXObjects.hx_response Method
julia
hx_response(val; trigger, push_url, replace_url, redirect, refresh, retarget, reswap, location)

Wrap any value (via to_response) and attach HX-* response headers that instruct the HTMX client to perform additional actions after swap.

source
HTMXObjects.hx_target Method

Return the HX-Target header value (the id of the target element), or "".

source
HTMXObjects.hx_trigger Method

Return the HX-Trigger header value (the id of the triggering element), or "".

source
HTMXObjects.is_htmx Method

Return true if the request was made by HTMX (has HX-Request: true header).

source
HTMXObjects.lazy Method
julia
lazy(url; tag=h.div, swap="outerHTML", kwargs...)

Return a placeholder element that fetches url via HTMX on page load and replaces itself with the response (hx-swap="outerHTML").

Useful for "render immediately, fill in asynchronously" patterns where each item in a list independently loads its own content after the page appears.

julia
# Status cell that loads asynchronously after the table renders
lazy(query_url("/pod_status"; pod=pod_id); tag=h.td, id="status-$pod_id")

Extra kwargs (e.g. id, style, class) are forwarded to the element.

source
HTMXObjects.linput Function
julia
linput(nv, placeholder=Long(nv); label=Long(nv), kwargs...)

A labeled text <input> wrapped in a <label>. All extra kwargs (e.g. hx_get, hx_target) are passed to the <input>.

source
HTMXObjects.loading_indicator_script Method
julia
loading_indicator_script()

Return a Node <script> that sets aria-busy on HTMX target elements during requests. Pico CSS renders a spinner automatically for aria-busy elements.

Note

Deprecated in favor of request_feedback() which provides richer visual feedback.

source
HTMXObjects.markdown_response Method
julia
markdown_response(text) -> HTTP.Response

Create a text/markdown HTTP response from a string.

source
HTMXObjects.nav_sidebar Method
julia
nav_sidebar(items::Vector{<:Pair}; prefix="", target="#content", active_class="contrast", inactive_class="secondary")

Render a Pico CSS sidebar <aside> with HTMX-enabled navigation links. Each item is a "Label" => "/path" pair. Links use hyperscript to toggle active styling.

julia
nav_sidebar(["Overview" => "/overview", "Settings" => "/settings"]; prefix="/app")
source
HTMXObjects.ninput Method
julia
ninput(nv; label=Long(nv), value=_avalue(nv, 0), min=nothing, max=nothing, step=nothing, show_when=nothing, kwargs...)

A labeled number <input type="number">. Only min, max, step that are not nothing are included as attributes. Extra kwargs go on the <input>.

source
HTMXObjects.parse_gallery_metadata Method
julia
parse_gallery_metadata(raw::AbstractString) -> Dict{String,String}

Parse # key: value header lines from a Julia source file. Stops at the first non-comment line. Comment lines without a : are ignored. Keys are lowercased. Standard keys: title, description, id, section. Apps may read additional keys directly from item.metadata.

source
HTMXObjects.pico_bridge Method
julia
pico_bridge()

<style> block remapping --htmxo-* to Pico CSS's color tokens. Include once at the page level when an HTMXO app uses Pico CSS, so HTMXO components pick up the host's accent / borders / status colors. Loaded automatically by htmx when pico_version is non-nothing.

source
HTMXObjects.pico_page Method
julia
pico_page(content; pico_version="2", class="container", extra_head=())

Thin wrapper around htmx for the canonical __page__ pattern: wrap content in h.main(class=class)(content) and assemble a full Pico-CSS HTML page. Replaces the repeated boilerplate

julia
__page__(content) = htmx(h.main(class="container")(content); pico_version="2")

with

julia
__page__(content) = pico_page(content)
# or with extras:
__page__(content) = pico_page(content; extra_head=(h.title("My App"),))
source
HTMXObjects.post_form Method
julia
post_form(url, children...; kwargs...)

Generate a complete inline POST form. Extra keyword arguments become hidden <input> fields. Positional children are inserted before the submit button.

julia
post_form("/respond/slug/approved";
    label="Approve", btn_class="btn btn-approve",
    hx_target="#list", hx_swap="innerHTML",
    msg="APPROVED.",
)
source
HTMXObjects.query_url Method
julia
query_url(path, obj; overrides...) -> String

Build a URL from path, auto-collecting every @param declared on obj's type that is actually present in the inbound request (_req_of(obj)). Params with no declared @param are ignored; params not present in the request are omitted (so the URL only carries what the user explicitly set, and defaults remain implicit). Explicit overrides always win and are emitted even if absent from the request.

Presence is checked against queryparams(_req_of(obj)) regardless of the inbound request method — query_url always builds GET-style URLs.

source
HTMXObjects.queryparams Method
julia
queryparams(req::HTTP.Request) -> Dict{String, Union{String, Vector{String}}}

Parse query parameters from the request URL. Unlike HTTP.queryparams (which returns Dict{String,String} and drops duplicate keys), this preserves all values: single-value keys map to a String, duplicate keys map to a Vector{String}.

julia
queryparams("http://x/?a=1&b=2&b=3")  #=> Dict("a" => "1", "b" => ["2", "3"])
source
HTMXObjects.radio_group Method
julia
radio_group(nv, options; label=Long(nv), value=_avalue(nv), show_when=nothing, kwargs...)

A <fieldset> of radio buttons. Each element of options becomes <label><input type="radio" .../> Label</label>. Options can be plain values or value => label pairs. Extra kwargs go on each <input>.

source
HTMXObjects.record! Method
julia
record!(app; record_dir, record_base="", paths=["/"], full=true, hx=true, markdown=true)

Drive each path through the in-process route handler with one or more header sets, writing recordings under record_dir. No subprocess, no HTTP listener — looks each path up via HTTP.Handlers.gethandler on CONTEXT[].service.router and invokes the handler with a manufactured HTTP.Request. The handler's existing save logic in _resolve_response writes the appropriate file shape.

Each path is hit once per enabled variant:

  • full=true — plain GET. Saves the page-wrapped HTML at <record_dir>/<path>.html.

  • hx=true — HX-Request: true. Saves the body fragment at <record_dir>/hx/<path>.html.

  • markdown=true — Accept: text/markdown. Saves the markdown view at <record_dir>/md/<path>.md (only if the route's to_markdown_string(val) succeeds; otherwise skipped).

Calls route!(app; record_dir, record_base) first to register the handlers with the recording config. Returns app.

Use this in place of the subprocess+HTTP recorder when you can drive an app from the same Julia process — much faster (no port, no warmup) and deterministic in CI.

source
HTMXObjects.render_caption Method
julia
render_caption(spec::CaptionSpec; actions=())

Return a <figcaption> node for spec. actions is an iterable of nodes (buttons, links, …) rendered on the right side of the caption header.

Use with_caption(spec, content; actions) to wrap content in a <figure> — this function returns just the <figcaption> so it can be embedded in a custom layout if needed.

source
HTMXObjects.render_table Method
julia
render_table(table; id=nothing, sortable=true, download=true, download_filename=nothing, caption=nothing, cell=nothing, class="striped", kwargs...)

Render any Tables.jl-compatible table (DataFrame, NamedTuple of vectors, etc.) as an h.table HTML node.

Keyword arguments

  • id: tbody element id (auto-generated if nothing)

  • sortable: add click-to-sort headers (default true; requires sortable_table_js() on the page)

  • download: add a "⬇ CSV" button (default true; requires download_table_js() on the page). Placed in the caption header when caption is given, otherwise above the table.

  • download_filename: filename for the CSV download (default: id * ".csv")

  • caption: a CaptionSpec to render above the table inside a <figure>

  • cell(value, column_name, row_index): custom cell renderer (default: string(value))

  • class: table CSS class (default "striped")

  • Extra kwargs are forwarded to h.table()

Example

julia
df = DataFrame(name=["Alice", "Bob"], score=[95, 87])
cap = CaptionSpec(; title="Scores", short="Ranked test scores.", long="Data collected 2026-Q1.")
page = htmx(
    h.body(
        render_table(df; download=true, caption=cap),
        sortable_table_js(), download_table_js()
    );
    pico_version="2"
)
source
HTMXObjects.request_feedback Method
julia
request_feedback()

Combined style + script nodes for automatic HTMX request feedback. Included by default in htmx().

source
HTMXObjects.request_feedback_script Method
julia
request_feedback_script()

JS that hooks into HTMX events to add visual feedback classes on the target element of each request.

source
HTMXObjects.request_feedback_style Method
julia
request_feedback_style()

CSS for automatic HTMX request feedback: pulsating border while in-flight, brief color flash on success/failure. Themed via --htmxo-accent, --htmxo-success, --htmxo-error (see htmxo_theme).

source
HTMXObjects.rinput Method
julia
rinput(nv; label=Long(nv), value=_avalue(nv, 50), min=0, max=100, step=1, show_when=nothing, kwargs...)

A labeled range slider <input type="range"> with a live <output> display. Extra kwargs go on the <input>.

source
HTMXObjects.route! Method
julia
route!(app; prefix="", record_dir=nothing, record_base="")

Register every @get/@post/@put/@patch/@delete/@ws route declared on app's @htmx struct (and all transitively-@included sub-structs) with the Oxygen router. Returns app.

  • prefix — mount the entire app under a URL prefix (e.g. prefix="api" puts the root route at /api/). Default: root ("").

  • record_dir — if set, every served response is also persisted to disk via save_response under that directory; intended for static export.

  • record_base — URL prefix to strip from saved file paths when record_dir is set (for deploying to a non-root subpath).

route! stores (prefix, record_dir) per type in an internal registry so the _reroute! hook emitted by @htmx can re-register routes on Revise reloads — you do not need to call route! again after editing a route body.

source
HTMXObjects.safely Method
julia
safely(f; obj=nothing, req=nothing)

Run f() and return its result; on exception, record the error and return the same renderable that __error__ would produce at the route level. Use this for widget-level error containment — e.g. one of several panels composed inside a route handler where a failure in one panel should not take down the whole page.

julia
safely(; obj=__self__) do
    h.article(... expensive rendering ...)
end

If obj defines __error__, that hook is used; otherwise the default article with the recorded uid is returned.

source
HTMXObjects.save_response Method
julia
save_response(record_dir, url_path, response; ext=".html")

Save a response body to disk, mirroring the URL path structure (/post/42 → record_dir/post/42<ext>). Enables later replay via a static file server. Pass ext=".md" for markdown recordings (under the /md/ subtree); see also _save_typed_response which picks the extension from the response's Content-Type.

source
HTMXObjects.serve Method
julia
serve(; host="127.0.0.1", port=8080, async=false, parallel=false, revise=nothing, kwargs...)

Start the HTTP server. Passes all keyword arguments through to Oxygen.Core.serve. When async=false (the default), blocks until interrupted and calls terminate on exit.

parallel controls request concurrency:

  • false — single-threaded (default)

  • true — multi-threaded on the :default threadpool (Oxygen's serveparallel)

  • :interactive — multi-threaded on the :interactive threadpool, leaving :default free for heavy computation. Launch julia with e.g. julia -t 8,4 for 8 computation threads and 4 request-handling threads.

source
HTMXObjects.show_when_script Method
julia
show_when_script()

Return a <script> that wires change listeners for all [data-show-when-field] elements. Include once per page (like loading_indicator_script).

Evaluates visibility on load and re-initializes on htmx:afterSettle for dynamically swapped content.

source
HTMXObjects.sinput Method
julia
sinput(nv, options; label=Long(nv), value=_avalue(nv), show_when=nothing, kwargs...)

A labeled <select> wrapped in a <label>. Each element of options is rendered via soption. Extra kwargs (e.g. hx_get, hx_target) go on the <select>.

When show_when=(field, op, value) is set, the label gets data-show-when-* attributes and class="u-hidden". Include show_when_script once per page to wire up the client-side visibility logic.

source
HTMXObjects.sinput_custom Method
julia
sinput_custom(nv, options; label=Long(nv), value=_avalue(nv), show_when=nothing, placeholder="Add custom…", kwargs...)

Like sinput, but includes a text input and button for adding custom options client-side. The custom value is appended to the <select> and auto-selected so it is included in form submissions. Duplicates are skipped. Extra kwargs go on the <select>.

source
HTMXObjects.soption Method
julia
soption(option; value=option, selected_value=nothing, kwargs...)
soption((value, label)::Union{Tuple,Pair}; kwargs...)

Render an <option> tag. When given a Pair or Tuple, the first element is the value attribute and the second is the display label.

source
HTMXObjects.sortable_table Method
julia
sortable_table(headers, rows; sortable=true, class="striped", id=nothing,
               download=false, download_filename=nothing, caption=nothing,
               kwargs...)

Lower-level table renderer for pre-built rows. Use when cells need rich attributes (data-status, hx-*, data-sort-value, …) that render_table's value-to-content callback can't carry. render_table itself delegates to this after materialising rows from a Tables.jl source.

Arguments

  • headers: Vector of header entries. Each entry is either:

    • a String — auto-wired into h.th(label; onclick="sortTable(i, this)", class="u-pointer") when sortable=true, or a plain h.th(label) otherwise.

    • any HTML node (e.g. h.th(""), h.th("Actions")) — used as-is. That's the per-column opt-out: pass a node and the auto-wiring is skipped.

  • rows: Vector{<:Node} of pre-built h.tr(...) rows. The caller controls every <td>, including data-sort-value for semantic sort keys (see sortable_table_js for the sort-key resolution rules) and id="row-X" / id="detail-X" for the paired-row pattern.

  • sortable: master toggle for the auto-wiring of String headers (default true). Pre-built h.th headers are unaffected.

Other keyword arguments behave the same as in render_table.

Example

julia
rows = [h.tr(h.td(h.code(name)),
             h.td(data_sort_value=string(w))(h.strong(status)))
        for (name, status, w) in entries]
sortable_table(["Name", "Status"], rows; id="entries", download=false)

Pair with sortable_table_js and sortable_table_styles.

source
HTMXObjects.sortable_table_js Method
julia
sortable_table_js()

Return an h.script(...) node containing the sortTable JavaScript function for click-to-sort table headers. Include this once per page (e.g. in extra_head).

The JS finds the <tbody> relative to the clicked header (no hardcoded ID), so multiple sortable tables can coexist on the same page. Numeric values are sorted numerically; everything else uses localeCompare.

source
HTMXObjects.sortable_table_styles Method
julia
sortable_table_styles()

Return an h.style(...) node with the canonical CSS for tables marked class="htmxo-sortable-table": every <th> gets cursor: pointer (sortable by default), and any <th> with a colspan (i.e. a group span over multiple columns) opts out and centres its text. Pair with sortable_table_js.

source
HTMXObjects.static_transform Method
julia
static_transform(val; record_base="")

Transform a value for static recording: disable non-functional elements, rewrite surviving hx-get URLs to point at the recorded fragment subtree under record_base, and inject the disabled-element style block (full pages only).

record_base is the URL prefix where the recording will be served (e.g. /HTMXObjects.jl/dev/examples/counter). Each rooted hx-get="/foo" becomes hx-get="<record_base>/hx/foo" so HTMX-driven sub-fetches resolve to the recorded fragment under <record_dir>/hx/foo.html. Empty record_base (default) skips rewriting — useful when recording for a root-served deployment, though that's rare in practice.

source
HTMXObjects.staticfiles Function
julia
staticfiles(folder, mountdir="static"; headers=[], loadfile=nothing)

Serve static files from folder at the URL prefix mountdir.

source
HTMXObjects.status_badge Method
julia
status_badge(state::Symbol; classes=_DEFAULT_STATUS_CLASSES, label=nothing)

Render a <span> badge for a status state, picking a u-text-* utility class matching the state. Override the display text with label (defaults to titlecased state name). Pass classes=Dict(:custom => "u-text-accent") to remap.

julia
status_badge(:running)                                  # u-text-warning
status_badge(:failed; label="Error!")                    # u-text-error "Error!"
status_badge(:custom; classes=Dict(:custom => "u-text-accent"))
source
HTMXObjects.tabset Method
julia
tabset(tabs::Pair...; active=1, id="tabset-$(hash(first.(tabs)))")

Client-side tabs using Pico CSS nav + hyperscript.

Eager (content rendered immediately):

julia
tabset("Tab 1" => content1, "Tab 2" => content2; active=1)

Lazy (content is a URL string, fetched via HTMX on first tab click):

julia
tabset("Tab 1" => "/api/tab1", "Tab 2" => "/api/tab2")

Mixed (eager + lazy):

julia
tabset("Summary" => render_summary(), "Details" => "/api/details")
source
HTMXObjects.tabset_styles Method
julia
tabset_styles()

CSS that turns tabset(...)'s Pico <nav> + .tab-panel markup into a classic tab strip: the active tab's border-bottom replaces the nav's horizontal rule along its own width, producing one continuous line.

Scoped to .tabset on the outer <div>, so plain <nav><ul> menus elsewhere are untouched. Auto-included by htmx(); inject manually via extra_head if you're building your own <head>.

source
HTMXObjects.terminate Method

Stop the HTTP server started by serve.

source
HTMXObjects.test_clear_cache! Function
julia
test_clear_cache!(test_module, md; prefix="") -> Node

Discard all cached test results for test_module, returning the refreshed empty-cache UI. Implemented by the TestExt extension.

source
HTMXObjects.test_list Function
julia
test_list(test_module, md; prefix="") -> Node

Render the test UI's main page: one row per @testset defined in test_module, with cached pass/fail/duration columns and per-row run buttons. Implemented by the TestExt extension (active when Test is loaded); mounted under TestRoutes at /tests/.

source
HTMXObjects.test_run! Function
julia
test_run!(test_module, name, md; prefix="") -> Node

Run a single @testset (identified by its display name) in test_module, update the cached result, and return the refreshed test_list UI fragment. Implemented by the TestExt extension.

source
HTMXObjects.test_run_all! Function
julia
test_run_all!(test_module, md; prefix="") -> Node

Run every @testset in test_module and return the refreshed test_list UI fragment. Implemented by the TestExt extension.

source
HTMXObjects.test_run_batch! Function
julia
test_run_batch!(test_module, names::AbstractString, md; prefix="") -> Node

Run a comma-separated batch of @testsets in test_module. Used by the test UI's batch-run controls. Implemented by the TestExt extension.

source
HTMXObjects.test_run_failed! Function
julia
test_run_failed!(test_module, md; prefix="") -> Node

Run every @testset in test_module whose cached result was a failure, and return the refreshed UI. Implemented by the TestExt extension.

source
HTMXObjects.test_run_missing! Function
julia
test_run_missing!(test_module, md; prefix="") -> Node

Run every @testset in test_module that has no cached result yet, and return the refreshed UI. Implemented by the TestExt extension.

source
HTMXObjects.tinput Method
julia
tinput(nv; label=Long(nv), value=_avalue(nv, ""), placeholder=Long(nv), rows=3, show_when=nothing, kwargs...)

A labeled <textarea>. Extra kwargs go on the <textarea>.

source
HTMXObjects.to_response Method
julia
to_response(val)

Convert any Julia value to an HTTP.Response. Handles Nodes, plain strings, arrays of either, and HTMX OOB-swap Pairs (via auto). Values that are already an HTTP.Response pass through unchanged.

source
HTMXObjects.vitepress_asset_dir Method
julia
vitepress_asset_dir() -> String

Absolute path of the bundled VitePress assets directory inside HTMXObjects. Contains htmxo-embed.ts, the canonical theme module that powers <div data-hx-base="…"> placeholders and .htmxo-embed link rewriting.

source
HTMXObjects.vitepress_bridge Method
julia
vitepress_bridge()

<style> block remapping --htmxo-* to VitePress's brand/state tokens. Include in VitePress's head (via config.mts) when embedding HTMXO recordings/fragments inside docs pages, so HTMXO components match the docs theme automatically. Falls back to HTMXO defaults when a token is missing.

source
HTMXObjects.vitepress_head_scripts Method
julia
vitepress_head_scripts(; htmx_version="2.0.8") -> String

The canonical head entries for VitePress's config.mts — loads HTMX from the jsdelivr CDN. Paste into the head: [ … ] list.

source
HTMXObjects.vitepress_proxy_config Method
julia
vitepress_proxy_config(; prefix="/live-htmxo", target_env="HTMXO_DEV_TARGET",
                          default_target="http://localhost:8101") -> String

The canonical vite.server.proxy entry forwarding <prefix>/* to a running HTMXObjects app during vitepress dev. In production the same markdown is backed by static recordings, so no proxy is needed.

source
HTMXObjects.vitepress_theme_enhanceapp_snippet Method
julia
vitepress_theme_enhanceapp_snippet() -> String

The canonical TS snippet to paste into a VitePress theme's enhanceApp({ router }). Already split into the import line and the function call so it slots into existing themes without restructuring.

source
HTMXObjects.vitepress_theme_install Method
julia
vitepress_theme_install(theme_dir; force=true) -> String

Copy the bundled htmxo-embed.ts into a VitePress theme directory. Call from make.jl before DocumenterVitepress runs:

julia
HTMXObjects.vitepress_theme_install(joinpath(@__DIR__, "src", ".vitepress", "theme"))

Then in the theme's index.ts:

julia
import { setupHtmxoEmbed } from './htmxo-embed';
// …inside enhanceApp({ router }):
setupHtmxoEmbed(router);

Returns the destination path. Overwrites by default so the asset stays in sync with the installed HTMXObjects version.

source
HTMXObjects.wants_errors Method
julia
wants_errors(req::HTTP.Request) -> Bool

Return true if the client wants only error-tagged elements (via ?error query parameter).

source
HTMXObjects.wants_markdown Method
julia
wants_markdown(req::HTTP.Request) -> Bool

Return true if the client wants a markdown response (for LLM/CLI consumption). Checks for Accept: text/markdown or text/plain header, or a ?plain / ?markdown query parameter.

source
HTMXObjects.with_caption Method
julia
with_caption(spec::CaptionSpec, content; actions=())

Wrap content (a single node or iterable of nodes) in a <figure> with the caption rendered above it. Pass actions (e.g. download buttons) to populate the right side of the caption header.

source
HTMXObjects.@htmx Macro
julia
@htmx struct MyApp ... end

Wraps @dynamicstruct and appends a _reroute! call so that Revise-triggered re-evaluation automatically re-registers routes without a server restart. @ws property bodies are automatically wrapped in (__ws__) -> body. Inline prop = struct ... end definitions are processed as nested route structs.

source
HTMXObjects.@query_url Macro
julia
@query_url prop(pos1, pos2; kw1=val1, kw2=val2)

Build a query_url from a property-call expression, following the same conventions as @get route definitions. Positional args become path segments, kwargs become query params.

Designed for use inside @htmx/@dynamicstruct bodies, where it intercepts the call before walk_rhs turns it into a property access.

julia
@query_url fit(; dataset="foo", model="bar")    # → query_url("/fit"; dataset="foo", model="bar")
@query_url item(42)                              # → query_url("/item/42")
@query_url item(id; format="json")               # → query_url("/item/$(id)"; format="json")
@query_url index                                 # → query_url("/")
source
You are viewing the dev branch. This branch may include code written with Claude Code with less human supervision. Only human-approved code is merged into main.