HTMX with Ktor
The DSL does not know anything special about HTMX. An hx-* attribute is just another attribute on a generated tag class or a plain kotlinx.html tag, set through the same attributes map covered in Using the DSL. This page covers the pattern this project actually uses for that, and the route shape a real HTMX swap needs, both taken from demo-ktor/, a working Ktor + HTMX application built against this DSL.
hx-* attributes as named extension functions
kotlinx.html has no built-in HTMX support, and writing attributes["hx-post"] = "/signup" at every call site works but reads worse than the rest of the DSL. The /htmx-ktor convention this project follows is a small, hand-written HTMLTag extension per attribute, defined once and reused everywhere:
import kotlinx.html.HTMLTag
fun HTMLTag.hxPost(url: String) {
attributes["hx-post"] = url
}
fun HTMLTag.hxTarget(selector: String) {
attributes["hx-target"] = selector
}
fun HTMLTag.hxSwap(strategy: String) {
attributes["hx-swap"] = strategy
}These are plain kotlinx.html extensions, not part of oblyx-ktor, so they work identically on a generated OBLYX* tag and on a native tag like form. A form built from Oblyx components reads the same as one hand-written in plain HTML:
A form wired for an HTMX swap
<form id="signup-form" hx-post="/signup" hx-target="#signup-form" hx-swap="outerHTML">
<oblyx-card-body>
<oblyx-input label="Email" name="email" type="email" required></oblyx-input>
</oblyx-card-body>
<oblyx-card-footer>
<oblyx-button variant="primary">Sign up</oblyx-button>
</oblyx-card-footer>
</form>form {
id = "signup-form"
hxPost("/signup")
hxTarget("#signup-form")
hxSwap("outerHTML")
oblyxCardBody {
oblyxInput(label = "Email", name = "email", type = OblyxInputType.EMAIL, required = true)
}
oblyxCardFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Sign up" }
}
}Route architecture: full pages and fragments never mix up
A route that renders a full page and one that renders a swap fragment return fundamentally different things: a complete <html> document versus a bare subtree with no surrounding page. Mixing the two up, a fragment route accidentally calling respondHtml, or a page route returning a bare fragment, is exactly the kind of bug that looks fine in isolation and breaks the first time HTMX swaps a full document into a <div>. demo-ktor avoids the class of bug entirely with two distinct route builders, each handing its body only the one responder it's allowed to call:
class PageCall(val call: ApplicationCall)
suspend fun PageCall.respondPage(block: HTML.() -> Unit) {
call.respondHtml { block() }
}
fun Route.pageRoute(path: String, body: suspend PageCall.() -> Unit) {
get(path) { PageCall(call).body() }
}
class FragmentCall(val call: ApplicationCall)
suspend fun FragmentCall.respondFragment(block: FlowContent.() -> Unit) {
val html = createHTML().div { block() }.removePrefix("<div>").removeSuffix("</div>")
call.respondText(html, ContentType.Text.Html)
}
fun Route.fragmentPostRoute(path: String, body: suspend FragmentCall.(parameters: Parameters) -> Unit) {
post(path) {
val parameters = call.receiveParameters()
FragmentCall(call).body(parameters)
}
}A fragment route built this way can only physically call respondFragment; there is no respondHtml in scope to reach for by mistake:
fragmentPostRoute("/signup") { parameters ->
respondFragment { signupForm(validateSignup(parameters)) }
}Every child element renders standalone in a fragment
POST /signup in the demo swaps only the <form>, not the <oblyx-card> around it on the full page. The fragment response therefore renders oblyxCardBody and oblyxCardFooter with no enclosing oblyxCard at all, which the generator supports on purpose (see "Standalone construction" on Using the DSL): every generated builder is a plain FlowContent extension, never scoped to a specific parent, because this is the normal shape of an HTMX partial, not a special case to design around.
The swapped-back fragment, unchanged from the page's own markup
Server-side validation put an error on the input; the surrounding oblyx-card from the full page is not part of this response.
<oblyx-card-body>
<oblyx-input label="Email" name="email" type="email" value="not-an-email" error="That email is already registered" required></oblyx-input>
</oblyx-card-body>
<oblyx-card-footer>
<oblyx-button variant="primary">Sign up</oblyx-button>
</oblyx-card-footer>// signupForm() below is called both by the full page's GET / and by the
// POST /signup fragment. Only the <form>...</form> subtree is swapped, so
// this exact same function call renders with no enclosing oblyx-card at
// all on the fragment path.
fun FlowContent.signupForm(state: SignupFormState) {
form {
id = "signup-form"
hxPost("/signup")
hxTarget("#signup-form")
hxSwap("outerHTML")
oblyxCardBody {
oblyxInput(
label = "Email",
name = "email",
type = OblyxInputType.EMAIL,
value = state.email,
error = state.emailError,
required = true,
)
}
oblyxCardFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Sign up" }
}
}
}The swapped-in <oblyx-input> and <oblyx-button> upgrade on insertion the same way they did on first paint, with no re-initialization call and nothing for the Ktor handler to do beyond returning the fragment. That is NFR-003: a component inserted after load behaves identically to one present at load.