HTMXObjects.jl
Property-based web apps for Julia. Built on DynamicObjects.jl, Oxygen.jl, and HTMX.jl.
| Page | What it covers |
|---|---|
| This page | The core idea (@dynamicstruct data + @htmx requests), routing, derived properties, response pipeline, error handling, async/polling, anti-patterns |
| Examples | Hello, Counter, Blog, Search, Tabs — each with source links |
| Components | Catalog of every form input, table renderer, layout widget, and helper — with minimal usage snippets |
| Testing | Mounting TestRoutes, the test_* runner functions, the web-integrated test workflow |
| API | Full 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
@dynamicstructtypes. This is the backend.Requests — ephemeral, regenerated on every HTTP request. URL handlers, HTML fragments, request-derived parameters, page layouts. Lives on
@htmxtypes. 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 init | Built fresh per request |
Vectors, DataFrames, @cached results | HTML fragments and page layouts |
| File / DB / network handles | @get / @post / … route properties |
Thread-safe mutation (cache_type=:parallel) | @param request-derived values |
| No HTTP knowledge | Has __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:
using HTMXObjects
create_app("MyApp.jl") # writes Project.toml, src/MyApp.jl, app/Run:
cd MyApp.jl && julia -i --project=app app/main.jlA minimal app written by hand:
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
endThat 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.
@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-levelconstthe app needs.Feature-scoped sub-structs. For anything non-trivial, give each feature its own
XData@dynamicstructand compose them as properties ofAppData. SeeBayesianRegressionModels(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@dynamicstructwithcache_type=:parallelor aThreadsafeDictfield.No module-level mutable state. Mutate through property accessors on
AppDataso 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.@includecomposes sub-structs under a path segment.@paramdeclares request-derived typed properties shared across routes.A
_reroute!hook fires on Revise re-evaluation so routes re-register without a server restart.
@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))
endA 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.
@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`
endIf 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:
@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
endInline form — keep a tightly scoped feature in one place:
@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
endBoth forms thread __req__, __appdata__, and __prefix__ automatically:
__parent__ = __self__— children see the enclosing struct.__prefix__ = parent.__prefix__ * "/<name>"— each@includeappends one segment.__appdata__falls through the__parent__chain viagetproperty.
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.
@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
endRoutes
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).
@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
endRules
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.:indexcollapses the URL segment.@get index() = …mounts at the enclosing prefix itself (no/indexsegment);@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@getproperties.Kwargs sourcing —
queryparams(req)on GET/DELETE;formdata(req)withqueryparamsfallback on POST/PUT/PATCH.Multi-value query params (
?tag=a&tag=b) — untyped kwargs receiveVector{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 aVerb{V}singleton injected as the first positional arg of every route LHS, so multiplecompute_propertymethods on the same property name coexist. There is no name mangling; only exact(name, verb)duplicates are rejected.
Register with route!
route!(app) # register routes
route!(app; record_dir="site") # + record every response to disk
route!(app; prefix="api") # mount everything under /apiThere 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.
@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)
endRules
Never wrap with
@cached.@paramis per-request state;@cachedwould 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
@getkwarg defaults, which are limited to literals).Inline
@includechildren inherit parent@params for free. External children do not — use the delegation form:
@htmx struct ChildRoutes
@param (; fit_key, top_chains) = __parent__ # register + resolve
(; plot_height) = __parent__ # plain destructure, not a @param
endquery_url(path, obj) — round-tripping params
@get plot() = begin
poll_url = query_url(__self__ / "data", __self__)
h.div(hx_get=poll_url, hx_trigger="every 1s")(...)
endquery_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):
HTTP.Response→ returned as-is.?erroron the query string →filter_errors(val)keeps onlye.*nodes and their ancestors.Markdown requested (
?markdown,?plain, orAccept: text/markdown/text/plain) →to_markdown_string(val).HTMX request (
HX-Request: true) → fragment returned as-is.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:
# 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:
__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(...):
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.
| Name | Set by | Purpose |
|---|---|---|
__self__ | @dynamicstruct | The current struct instance. |
__req__ | route! handler (per request) | The inbound HTTP.Request. |
__ws__ | @ws body wrapper | The WebSocket handle inside @ws bodies. |
__parent__ | @include desugar | Parent struct instance in a sub-struct. |
__prefix__ | route! + @include | Current 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) + @include | App data; falls through the __parent__ chain. |
__page__ | User | content -> 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:
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:
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.
@htmx struct AppRoutes
@cached running = false
@post toggle() = begin
running = !running # rewritten to __self__.running = !running
@persist running # flush to disk
render_ui()
end
endMost @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:
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 formname() = expris still an IP. IPs are backed by aThreadsafeDict(undercache_type=:parallel, the usual setting forAppData), spawn and deduplicateTasks per key, and are pollable.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.@cachedONLY 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@cachedwhen (and only when) you want the result to survive a server restart.
The canonical pattern
# 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
endWhat polling_fetchindex(ip, args...; poll_url, label, force=false) do result ... end actually does:
First call: kicks off the
Taskfor@memo ip(args...)(viaThreadsafeDict.get!under the hood — deduplicating concurrent requests for the same args), and returns a progress fragment withhx-get=poll_url+hx-trigger="every Ns". The HTMX client polls.Subsequent polls hit
poll_urland re-enter the handler:Still running → same progress fragment.
Failed → error article.
Resolved → the
do resultblock's rendering.
force=trueinvalidates 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:
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
endAll 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:
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)))
endThis 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):
# 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:
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
endKnown users of the non-nothing form:
BRM's
compute_steps— warms thepathfinderandposterior_warmupIPs under the matching phase's progress.bruno's
_rpkpd(web-pkpd/src/analysis/sim_state.jl) — forwards the caller's status intopkpd'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
progressinto 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 bareip[args]— see the access-form note below.
Translating the rules into action
Make a property pollable → lift it to function form (
name(args...) = exprorname() = expr). Do not add@cachedfor 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(orwith_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 (whatfetchindex/polling_fetchindexdispatch through).@memois the public face of the caching; avoid reaching for bareo.prop[args]directly. The brackets work — in fact@memorewrites to them — but writing[]at the call site hides the fact that something magical (Task spawn, memoised lookup) is happening. Treatprop[args]as an implementation detail that@memowraps.
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 setThe 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:
Generates a short uid (
hash(time_ns())).Writes
$ERROR_DIR[]/<uid>.log(default/tmp/htmxo_errors/<uid>.log) containing uid, ISO timestamp, method + target, andshowerror(io, err, bt).Emits one
@error "HTMXObjects caught an error: <full path>"— terse; the full stack trace is on disk.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 anHTTP.Responsedirectly, its status is respected — no rewrite.
Customising
@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)))
endWidget-level containment — safely
For composite routes (dashboards with several independent panels), wrap each panel in safely so one failure doesn't kill the others:
@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
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-deleteattributes.Strips
hx-getattributes 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:
python -m http.server --directory siteRevise hot-reload
| Change | Hot-reloaded? |
|---|---|
| Plain function in the package / web module | Yes |
@dynamicstruct / @htmx struct body | Yes |
New @get/@post added to a struct | Yes (via _reroute!) |
@testset body in web/src/test/runtests.jl | Yes |
@testset via ../ path outside the pkg tree | No — source-text cache mismatch |
@generated function helper | No — world-age frozen |
Revise.includet(SubModule, file) | Breaks all tracking — never use |
const value change | No — 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.
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.@__strembeds inline Hyperscript:h.button(__"on click toggle .hidden on #menu")("Toggle").HTMX inherits
hx-swap,hx-target,hx-triggerfrom ancestors. Usehx-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:
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:
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—@dynamicstructowning 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—@htmxmountable via@includeunder a parent that exposeseditor(GitRepo.editor(relpath)) andcontainer_id::Stringlocals. 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
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→:interactivethreadpool, leaving:defaultfree for heavy computation. Launch Julia with e.g.julia -t 8,4for 8 computation threads + 4 request-handling threads.revise=:lazyis 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.).@paramdeclarations.Action handlers that mutate state on
AppDataand 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 : fulldual-path. The pipeline does this.if wants_markdown(__req__) ... else ... endbranches. Ditto.Plain
Dict{K,V}()at module top level. Oxygen is concurrent.const FOO = ...besidesAPPDATA.Standalone
function foo(...) ... endat module top level. Promote to a property on the owning struct. Plainfunctionblocks inside a@htmx/@dynamicstructbody 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 Cfor form-driven data. Julia's@enumhas noparsemethod. UseStringorSymbol.Custom Oxygen route handlers alongside
route!. Extendroute!instead.
Further reading
Web app pattern (KB) — directory layout,
web/Project.tomlsync,start-web.sh.Testing pattern (KB) —
web/src/test/+ symlink,TestModules.@testset, test UI.Revise notes (KB) — what Revise does and does not hot-reload.
API reference
HTMXObjects.ERROR_DIRHTMXObjects._FORM_KEYSHTMXObjects.CaptionSpecHTMXObjects.EditorRoutesHTMXObjects.GalleryHTMXObjects.GalleryItemHTMXObjects.HtmlOnlyHTMXObjects.MIMEResponseHTMXObjects.MarkdownOnlyHTMXObjects.RecordingRoutesHTMXObjects.SharedOpsRoutesHTMXObjects.StructureRoutesHTMXObjects.TestRoutesHTMXObjects.VerbHTMXObjects.LongHTMXObjects._convert_include_to_struct!HTMXObjects._convert_params!HTMXObjects._default_error_renderHTMXObjects._disable_for_staticHTMXObjects._extract_paramHTMXObjects._has_pageHTMXObjects._inject_dunder_props!HTMXObjects._inject_include_prefix!HTMXObjects._inject_static_styleHTMXObjects._inject_verb_in_route_lhs!HTMXObjects._interactive_stream_handlerHTMXObjects._lookup_paramHTMXObjects._page_wrapperHTMXObjects._print_req_metaHTMXObjects._register_route_handlerHTMXObjects._req_ofHTMXObjects._resolve_responseHTMXObjects._resolve_response_nestedHTMXObjects._save_typed_responseHTMXObjects._warn_hardcoded_url_in_attrs!HTMXObjects._warn_redundant_req_decl!HTMXObjects._wrap_ws_bodies!HTMXObjects.ainputHTMXObjects.app_layoutHTMXObjects.caption_styleHTMXObjects.cinputHTMXObjects.create_appHTMXObjects.create_appHTMXObjects.default_gallery_cardHTMXObjects.download_table_jsHTMXObjects.dynamicfilesHTMXObjects.eHTMXObjects.editor_formHTMXObjects.editor_stylesHTMXObjects.escape_htmlHTMXObjects.filter_errorsHTMXObjects.fmt_bytesHTMXObjects.fmt_numberHTMXObjects.fmt_timeHTMXObjects.gallery_controls_scriptHTMXObjects.gallery_gridHTMXObjects.gallery_toolbarHTMXObjects.get_formHTMXObjects.hidden_inputsHTMXObjects.html_escapeHTMXObjects.htmxHTMXObjects.htmx_orHTMXObjects.htmx_tabsetHTMXObjects.htmxo_breadcrumbHTMXObjects.htmxo_embed_htmlHTMXObjects.htmxo_gallery_stylesHTMXObjects.htmxo_syntax_headHTMXObjects.htmxo_themeHTMXObjects.htmxo_utility_stylesHTMXObjects.hx_boostedHTMXObjects.hx_current_urlHTMXObjects.hx_linkHTMXObjects.hx_promptHTMXObjects.hx_responseHTMXObjects.hx_targetHTMXObjects.hx_triggerHTMXObjects.is_htmxHTMXObjects.lazyHTMXObjects.linputHTMXObjects.loading_indicator_scriptHTMXObjects.markdown_responseHTMXObjects.nav_sidebarHTMXObjects.ninputHTMXObjects.parse_gallery_metadataHTMXObjects.pico_bridgeHTMXObjects.pico_pageHTMXObjects.post_formHTMXObjects.query_urlHTMXObjects.queryparamsHTMXObjects.radio_groupHTMXObjects.record!HTMXObjects.render_captionHTMXObjects.render_tableHTMXObjects.request_feedbackHTMXObjects.request_feedback_scriptHTMXObjects.request_feedback_styleHTMXObjects.rinputHTMXObjects.route!HTMXObjects.route!HTMXObjects.safelyHTMXObjects.save_responseHTMXObjects.serveHTMXObjects.show_when_scriptHTMXObjects.sinputHTMXObjects.sinput_customHTMXObjects.soptionHTMXObjects.sortable_tableHTMXObjects.sortable_table_jsHTMXObjects.sortable_table_stylesHTMXObjects.static_transformHTMXObjects.staticfilesHTMXObjects.status_badgeHTMXObjects.tabsetHTMXObjects.tabset_stylesHTMXObjects.terminateHTMXObjects.test_clear_cache!HTMXObjects.test_listHTMXObjects.test_run!HTMXObjects.test_run_all!HTMXObjects.test_run_batch!HTMXObjects.test_run_failed!HTMXObjects.test_run_missing!HTMXObjects.tinputHTMXObjects.to_responseHTMXObjects.vitepress_asset_dirHTMXObjects.vitepress_bridgeHTMXObjects.vitepress_head_scriptsHTMXObjects.vitepress_proxy_configHTMXObjects.vitepress_theme_enhanceapp_snippetHTMXObjects.vitepress_theme_installHTMXObjects.wants_errorsHTMXObjects.wants_markdownHTMXObjects.with_captionHTMXObjects.@htmxHTMXObjects.@htmxHTMXObjects.@query_url
HTMXObjects.ERROR_DIR Constant
ERROR_DIRDirectory 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.
HTMXObjects._FORM_KEYS Constant
_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.
HTMXObjects.CaptionSpec Type
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(...))).
HTMXObjects.EditorRoutes Type
EditorRoutesGit-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 viaeditor_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 = 15placeholder::String = ""label = nothing
See the section_routes demo in HTMXObjects/web/src/git_editor_demo.jl.
HTMXObjects.Gallery Type
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.
HTMXObjects.GalleryItem Type
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 toid; 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
HTMXObjects.HtmlOnly Type
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.
sourceHTMXObjects.MIMEResponse Type
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.
@get aov_runtime_js = MIMEResponse("application/javascript",
strip_script(sprint(show, MIME"text/html"(), vega_runtime())))HTMXObjects.MarkdownOnly Type
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.
sourceHTMXObjects.RecordingRoutes Type
@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.
HTMXObjects.SharedOpsRoutes Type
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
@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).
HTMXObjects.StructureRoutes Type
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 fromroot, with per-type lint badges (✗Nerrors,⚠Nwarns) fromDynamicObjects.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, anddependsonset./structure/callers?name=T&prop=p— every site that calls a property namedp(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.
sourceHTMXObjects.TestRoutes Type
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:
@include tests = TestRoutes(; test_module=@__MODULE__)Provides:
@get index— render the cached test list (seetest_list)@post run(name)— re-run a single named@testset(seetest_run!)@post run_all— run every test (seetest_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).
HTMXObjects.Long Method
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".
HTMXObjects._convert_include_to_struct! Method
_convert_include_to_struct!(struct_expr)Pre-process @include lines:
@include prop = begin...endis rewritten toprop = struct _Include_prop ... endso 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.
HTMXObjects._convert_params! Method
_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:
@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 parentThe 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.
HTMXObjects._default_error_render Method
_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.
HTMXObjects._disable_for_static Method
_disable_for_static(val) -> valWalk a Node tree and disable elements that won't work on a static server:
Strip
hx-post,hx-put,hx-patch,hx-deleteattributesStrip
hx-getattributes whose URL contains?(query-param routes)Strip
hx-getattributes pointing to kwargs routes (from_static_kwargs_paths)Mark affected elements with
data-static-disabledanddisabled
Non-Node values pass through unchanged.
sourceHTMXObjects._extract_param Function
_extract_param(req, name, T, default=_NO_DEFAULT) -> valueExtract 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}).
HTMXObjects._has_page Method
_has_page(obj) -> BoolTrue if obj defines either __page__ or the legacy page property.
HTMXObjects._inject_dunder_props! Method
_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@includedesugar to point at the enclosing struct.__prefix__ = ""— root-level mount path (no leading/);@includebuildsparent.__prefix__ * "/childname"for each nested sub-struct.__req__/__appdata__— fall through__parent__if present, elsenothing.__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 structbody (__appdata__ = APP_DATAwithconst APP_DATA = AppData()at module scope) so the singleton is part of the struct's definition, not ofroute!'s API.__route__ = ""— defaults to empty;_register_route_handlersets it toreq.target(with query string stripped) when constructing the struct for a real request, so route bodies can writehx_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.
HTMXObjects._inject_include_prefix! Method
_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.
HTMXObjects._inject_static_style Method
_inject_static_style(val)If val is a full HTML page (contains a <head>), inject the disabled-element style block.
HTMXObjects._inject_verb_in_route_lhs! Method
_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:
@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.
HTMXObjects._interactive_stream_handler Method
_interactive_stream_handler(middleware::Function)Like Oxygen's stream_handler + parallel_stream_handler, but spawns each request on the :interactive threadpool instead of :default.
HTMXObjects._lookup_param Method
_lookup_param(src, fallback, name, T) -> value or _NO_DEFAULTLow-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.
HTMXObjects._page_wrapper Method
_page_wrapper(obj) -> wrapper or nothingLook 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!).
HTMXObjects._print_req_meta Method
_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.
HTMXObjects._register_route_handler Method
_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:
Constructs the root struct.
Walks the include chain if any.
Calls
_extract_args(LeafT, Val(name), Verb{V}(), req, base, n_params)to parse URL/query/form params.Invokes
prop(Verb{V}(), idx_vals…; kw…)whereprop = 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.
HTMXObjects._req_of Method
_req_of(obj) -> HTTP.Request or nothingLook up the framework-managed request on obj. @htmx always injects __req__ (defaulting to nothing) when not user-declared.
HTMXObjects._resolve_response Method
_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 namepagestill works with a deprecation warning.to_markdown(val)— custom markdown serializer
If the route returns an HTTP.Response directly, it passes through unchanged.
HTMXObjects._resolve_response_nested Method
Like _resolve_response, but applies nested page wrappers (innermost first, then outward).
HTMXObjects._save_typed_response Method
_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.
HTMXObjects._warn_hardcoded_url_in_attrs! Method
_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.
sourceHTMXObjects._warn_redundant_req_decl! Method
_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.
HTMXObjects._wrap_ws_bodies! Method
_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.
HTMXObjects.ainput Method
ainput(nv; kwargs...)Smart h.input wrapper that accepts a NamedTuple or String. A NamedTuple like (; fit_key) extracts name and value automatically:
ainput((; fit_key); type="hidden")
# equivalent to: h.input(; name="fit_key", value=fit_key, type="hidden")HTMXObjects.app_layout Method
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:
.htmxo-app-layout { --htmxo-sidebar-width: 10rem; }__page__(content) = htmx(
app_layout(
nav_sidebar(["Table" => "/", "Gallery" => "/gallery"]),
content,
);
pico_version="2",
)HTMXObjects.caption_style Method
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.
HTMXObjects.cinput Method
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>.
HTMXObjects.create_app Method
create_app(path; port=8000, module_name=nothing, setup=true)Generate a new HTMXObjects app at path with the standard package + app structure:
path/
├── Project.toml
├── src/<ModuleName>.jl
└── app/
├── Project.toml
└── main.jlmodule_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:
cd path && julia -i --project=app app/main.jlHTMXObjects.default_gallery_card Method
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.
HTMXObjects.download_table_js Method
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.
HTMXObjects.dynamicfiles Function
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).
HTMXObjects.e Method
eError-tagged HTML builder. Works exactly like h, but adds data-error="true" to every element. Elements with this attribute survive the ?error filter.
e.div(id="foo")("content") # <div id="foo" data-error="true">content</div>
e.span("error!") # <span data-error="true">error!</span>HTMXObjects.editor_form Method
editor_form(; id, post_url, content="", version="", ...) -> NodeInline 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 issueshx-getto 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 (defaulth.div).class— outer element class.post_url— URL for Save.target_id—hx-targetfor Save response (defaultid).swap—hx-swapvalue (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—:textareaor: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.
HTMXObjects.editor_styles Method
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>.
HTMXObjects.escape_html Method
escape_html(s) -> StringEscape &, ", ', <, > 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.
HTMXObjects.filter_errors Method
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.
HTMXObjects.fmt_bytes Method
fmt_bytes(n) -> StringFormat a byte count n with appropriate binary units (B, KB, MB, GB, TB).
HTMXObjects.fmt_number Method
fmt_number(x; sigdigits=2) -> StringFormat a number with SI suffixes (K, M, B, T) or scientific notation for very small values.
sourceHTMXObjects.fmt_time Method
fmt_time(t) -> StringFormat 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".
HTMXObjects.gallery_controls_script Method
gallery_controls_script() -> StringThe 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).
HTMXObjects.gallery_grid Method
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
setupHtmxoEmbedfromhtmxo-embed.tsre-executes inline scripts inside.htmxo-embedafter 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.
HTMXObjects.gallery_toolbar Method
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".
HTMXObjects.get_form Method
get_form(url, children...; kwargs...)Generate a complete inline GET form. Same API as post_form but uses hx-get instead of hx-post.
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),
)Method
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:
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).
HTMXObjects.html_escape Method
html_escape(s) -> StringEscape &, <, > 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.
HTMXObjects.htmx Method
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).
HTMXObjects.htmx_or Method
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:
htmx_or(req, fragment) do
htmx(h.main(search_input(q), fragment))
endHTMXObjects.htmx_tabset Method
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).
HTMXObjects.htmxo_breadcrumb Method
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.
htmxo_breadcrumb([
("Home", "/", "/"),
("Projects", "/projects", "/projects"),
("Detail", nothing, nothing),
])HTMXObjects.htmxo_embed_html Method
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:
println(io, htmxo_embed_html(; base="live-aov/"))HTMXObjects.htmxo_gallery_styles Method
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.
HTMXObjects.htmxo_syntax_head Method
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(...):
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).
HTMXObjects.htmxo_theme Method
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.
HTMXObjects.htmxo_utility_styles Method
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-*tokensu-mt-*etc. margin / padding scale:0,1(0.25rem),2(0.5rem),3(0.75rem),4(1rem),5(1.5rem),6(2rem)
HTMXObjects.hx_boosted Method
Return true if the request was boosted by HTMX (HX-Boosted: true header).
HTMXObjects.hx_current_url Method
Return the HX-Current-URL header value (the browser URL at the time of the request), or "".
HTMXObjects.hx_link Method
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.
HTMXObjects.hx_prompt Method
Return the HX-Prompt header value (the user's response to hx-prompt), or "".
HTMXObjects.hx_response Method
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.
HTMXObjects.hx_target Method
Return the HX-Target header value (the id of the target element), or "".
HTMXObjects.hx_trigger Method
Return the HX-Trigger header value (the id of the triggering element), or "".
HTMXObjects.is_htmx Method
Return true if the request was made by HTMX (has HX-Request: true header).
HTMXObjects.lazy Method
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.
# 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.
HTMXObjects.linput Function
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>.
HTMXObjects.loading_indicator_script Method
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.
HTMXObjects.markdown_response Method
markdown_response(text) -> HTTP.ResponseCreate a text/markdown HTTP response from a string.
HTMXObjects.nav_sidebar Method
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.
nav_sidebar(["Overview" => "/overview", "Settings" => "/settings"]; prefix="/app")HTMXObjects.ninput Method
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>.
HTMXObjects.parse_gallery_metadata Method
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.
HTMXObjects.pico_bridge Method
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.
HTMXObjects.pico_page Method
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
__page__(content) = htmx(h.main(class="container")(content); pico_version="2")with
__page__(content) = pico_page(content)
# or with extras:
__page__(content) = pico_page(content; extra_head=(h.title("My App"),))HTMXObjects.post_form Method
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.
post_form("/respond/slug/approved";
label="Approve", btn_class="btn btn-approve",
hx_target="#list", hx_swap="innerHTML",
msg="APPROVED.",
)HTMXObjects.query_url Method
query_url(path, obj; overrides...) -> StringBuild 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.
HTMXObjects.queryparams Method
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}.
queryparams("http://x/?a=1&b=2&b=3") #=> Dict("a" => "1", "b" => ["2", "3"])HTMXObjects.radio_group Method
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>.
HTMXObjects.record! Method
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'sto_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.
sourceHTMXObjects.render_caption Method
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.
HTMXObjects.render_table Method
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 ifnothing)sortable: add click-to-sort headers (defaulttrue; requiressortable_table_js()on the page)download: add a "⬇ CSV" button (defaulttrue; requiresdownload_table_js()on the page). Placed in the caption header whencaptionis given, otherwise above the table.download_filename: filename for the CSV download (default:id * ".csv")caption: aCaptionSpecto 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
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"
)HTMXObjects.request_feedback Method
request_feedback()Combined style + script nodes for automatic HTMX request feedback. Included by default in htmx().
HTMXObjects.request_feedback_script Method
request_feedback_script()JS that hooks into HTMX events to add visual feedback classes on the target element of each request.
sourceHTMXObjects.request_feedback_style Method
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).
HTMXObjects.rinput Method
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>.
HTMXObjects.route! Method
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 viasave_responseunder that directory; intended for static export.record_base— URL prefix to strip from saved file paths whenrecord_diris 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.
HTMXObjects.safely Method
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.
safely(; obj=__self__) do
h.article(... expensive rendering ...)
endIf obj defines __error__, that hook is used; otherwise the default article with the recorded uid is returned.
HTMXObjects.save_response Method
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.
HTMXObjects.serve Method
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:defaultthreadpool (Oxygen'sserveparallel):interactive— multi-threaded on the:interactivethreadpool, leaving:defaultfree for heavy computation. Launch julia with e.g.julia -t 8,4for 8 computation threads and 4 request-handling threads.
HTMXObjects.show_when_script Method
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.
HTMXObjects.sinput Method
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.
HTMXObjects.sinput_custom Method
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>.
HTMXObjects.soption Method
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.
HTMXObjects.sortable_table Method
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:Vectorof header entries. Each entry is either:a
String— auto-wired intoh.th(label; onclick="sortTable(i, this)", class="u-pointer")whensortable=true, or a plainh.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-builth.tr(...)rows. The caller controls every<td>, includingdata-sort-valuefor semantic sort keys (seesortable_table_jsfor the sort-key resolution rules) andid="row-X"/id="detail-X"for the paired-row pattern.sortable: master toggle for the auto-wiring of String headers (defaulttrue). Pre-builth.thheaders are unaffected.
Other keyword arguments behave the same as in render_table.
Example
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.
HTMXObjects.sortable_table_js Method
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.
HTMXObjects.sortable_table_styles Method
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.
HTMXObjects.static_transform Method
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.
HTMXObjects.staticfiles Function
staticfiles(folder, mountdir="static"; headers=[], loadfile=nothing)Serve static files from folder at the URL prefix mountdir.
HTMXObjects.status_badge Method
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.
status_badge(:running) # u-text-warning
status_badge(:failed; label="Error!") # u-text-error "Error!"
status_badge(:custom; classes=Dict(:custom => "u-text-accent"))HTMXObjects.tabset Method
tabset(tabs::Pair...; active=1, id="tabset-$(hash(first.(tabs)))")Client-side tabs using Pico CSS nav + hyperscript.
Eager (content rendered immediately):
tabset("Tab 1" => content1, "Tab 2" => content2; active=1)Lazy (content is a URL string, fetched via HTMX on first tab click):
tabset("Tab 1" => "/api/tab1", "Tab 2" => "/api/tab2")Mixed (eager + lazy):
tabset("Summary" => render_summary(), "Details" => "/api/details")HTMXObjects.tabset_styles Method
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>.
HTMXObjects.test_clear_cache! Function
test_clear_cache!(test_module, md; prefix="") -> NodeDiscard all cached test results for test_module, returning the refreshed empty-cache UI. Implemented by the TestExt extension.
HTMXObjects.test_list Function
test_list(test_module, md; prefix="") -> NodeRender 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/.
HTMXObjects.test_run! Function
test_run!(test_module, name, md; prefix="") -> NodeRun 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.
HTMXObjects.test_run_all! Function
test_run_all!(test_module, md; prefix="") -> NodeRun every @testset in test_module and return the refreshed test_list UI fragment. Implemented by the TestExt extension.
HTMXObjects.test_run_batch! Function
test_run_batch!(test_module, names::AbstractString, md; prefix="") -> NodeRun a comma-separated batch of @testsets in test_module. Used by the test UI's batch-run controls. Implemented by the TestExt extension.
HTMXObjects.test_run_failed! Function
test_run_failed!(test_module, md; prefix="") -> NodeRun every @testset in test_module whose cached result was a failure, and return the refreshed UI. Implemented by the TestExt extension.
HTMXObjects.test_run_missing! Function
test_run_missing!(test_module, md; prefix="") -> NodeRun every @testset in test_module that has no cached result yet, and return the refreshed UI. Implemented by the TestExt extension.
HTMXObjects.tinput Method
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>.
HTMXObjects.to_response Method
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.
HTMXObjects.vitepress_asset_dir Method
vitepress_asset_dir() -> StringAbsolute 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.
HTMXObjects.vitepress_bridge Method
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.
HTMXObjects.vitepress_head_scripts Method
vitepress_head_scripts(; htmx_version="2.0.8") -> StringThe canonical head entries for VitePress's config.mts — loads HTMX from the jsdelivr CDN. Paste into the head: [ … ] list.
HTMXObjects.vitepress_proxy_config Method
vitepress_proxy_config(; prefix="/live-htmxo", target_env="HTMXO_DEV_TARGET",
default_target="http://localhost:8101") -> StringThe 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.
HTMXObjects.vitepress_theme_enhanceapp_snippet Method
vitepress_theme_enhanceapp_snippet() -> StringThe 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.
HTMXObjects.vitepress_theme_install Method
vitepress_theme_install(theme_dir; force=true) -> StringCopy the bundled htmxo-embed.ts into a VitePress theme directory. Call from make.jl before DocumenterVitepress runs:
HTMXObjects.vitepress_theme_install(joinpath(@__DIR__, "src", ".vitepress", "theme"))Then in the theme's index.ts:
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.
sourceHTMXObjects.wants_errors Method
wants_errors(req::HTTP.Request) -> BoolReturn true if the client wants only error-tagged elements (via ?error query parameter).
HTMXObjects.wants_markdown Method
wants_markdown(req::HTTP.Request) -> BoolReturn 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.
HTMXObjects.with_caption Method
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.
HTMXObjects.@htmx Macro
@htmx struct MyApp ... endWraps @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.
HTMXObjects.@query_url Macro
@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.
@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("/")