Using the DSL
oblyx-ktor is a set of kotlinx.html builder functions generated from the same manifest <AttributeTable> reads. Every shipped custom element gets a builder function, a generated tag class, and an enum per closed value set. It reads like the rest of kotlinx.html because it is built the same way, with real Kotlin types standing in for what would otherwise be hand-spelled attribute strings.
A component, in HTML and in the DSL
<oblyx-button variant="primary">Save</oblyx-button>oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Save" }How the mapping works
The generator (packages/oblyx-ktor-generator/generate.mjs) reads custom-elements.json and applies the same three rules to every component, so there is one mapping to learn rather than one per component:
- Tag name to builder function. The manifest tag name, minus the shared
oblyx-prefix, becomes a camelCase function name prefixed back withoblyx:oblyx-card-headerbecomesoblyxCardHeader,oblyx-buttonbecomesoblyxButton. - Attribute to parameter. A kebab-case HTML attribute becomes a camelCase named parameter on the builder function:
icon-onlybecomesiconOnly,hrefstayshref. Every parameter also exists as a settable property of the same name on the generated tag class, for use inside the trailing block. - Closed value set to enum. An attribute with a fixed set of string values (a TypeScript literal union in the component source) becomes its own enum, named
Oblyx<Component><Attribute>:oblyx-button'svariantattribute becomesOblyxButtonVariant. Enum constants are SCREAMING_SNAKE_CASE with word boundaries taken from both hyphens and camelCase in the source value, sowrap-reversebecomesWRAP_REVERSEand an icon name likechevronDownbecomesCHEVRON_DOWN.
A closed value set becomes an enum
oblyx-input's "type" attribute has a fixed set of values.
<oblyx-input label="Email" type="email" required></oblyx-input>oblyxInput(
label = "Email",
type = OblyxInputType.EMAIL,
required = true,
)An attribute with no closed value set, like label or placeholder, is a plain nullable String parameter instead. Nothing about it looks different from an ordinary kotlinx.html attribute:
An open string attribute stays a String
<oblyx-input label="Email" name="email" placeholder="you@example.com"></oblyx-input>oblyxInput(
label = "Email",
name = "email",
placeholder = "you@example.com",
)Boolean attributes
A boolean attribute like disabled is a Boolean parameter that defaults to false. Passing true renders the attribute present with an empty value (disabled="", the same shape a plain kotlinx.html disabled = true produces); passing false, or omitting the parameter, renders no attribute at all. There is no ="true" string anywhere on the wire.
Boolean presence, not a string
<oblyx-button disabled>Save</oblyx-button>
<oblyx-button>Save</oblyx-button>oblyxButton(disabled = true) { +"Save" }
oblyxButton { +"Save" }Nesting and child content
Every generated builder takes a trailing lambda with the tag class as its receiver, exactly like a native kotlinx.html tag. Child components and plain kotlinx.html content nest inside it the same way:
Nested components
<oblyx-card>
<oblyx-card-header>Sign up</oblyx-card-header>
<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>
</oblyx-card>oblyxCard {
oblyxCardHeader { +"Sign up" }
oblyxCardBody {
oblyxInput(label = "Email", name = "email", type = OblyxInputType.EMAIL, required = true)
}
oblyxCardFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Sign up" }
}
}A component with no meaningful children, like oblyx-input, still accepts a block (it is optional and defaults to an empty one) so a caller who wants to set an extra attribute on the tag class from inside the block always can, even when there is nothing to nest inside it.
Standalone construction
Every builder is a plain FlowContent extension function, never scoped to a specific parent's tag class. That is a deliberate choice, not an oversight: an HTMX fragment response routinely renders a child element with no enclosing parent in the markup at all, and a builder that only compiled inside its parent's block would make that call site uncompilable exactly where this library exists to be used. oblyx-card-header,oblyx-card-body and oblyx-card-footer are visually meaningful only inside an oblyx-card, but each one is independently constructible:
A child element with no enclosing parent
This is the normal shape of an HTMX fragment response, not a special case.
<oblyx-card-body>Fragment content, no enclosing card.</oblyx-card-body>
<oblyx-card-footer>
<oblyx-button variant="primary">Sign up</oblyx-button>
</oblyx-card-footer>// Renders correctly with no enclosing oblyxCard { } at all.
oblyxCardBody { +"Fragment content, no enclosing card." }
oblyxCardFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Sign up" }
}Passing arbitrary attributes through
A generated tag class extends kotlinx.html's own HTMLTag, so anything kotlinx.html already provides works unchanged: id, classes, style, and the standard ARIA and event attributes. For anything the typed parameter list does not cover, such as an hx-* or data-* attribute, set it directly on the tag's own attributes map from inside the block, the same way you would on a plain kotlinx.html tag with no dedicated Kotlin property for it:
An hx-* or data-* attribute passes through unchanged
See "HTMX with Ktor" for the small named-extension-function pattern this project uses for hx-* attributes at real call sites, rather than the raw attributes[] map shown here.
<oblyx-button variant="primary" data-testid="signup-submit">Sign up</oblyx-button>oblyxButton(variant = OblyxButtonVariant.PRIMARY) {
attributes["data-testid"] = "signup-submit"
+"Sign up"
}A note on the generated tag class names
The generated tag classes are run-together capitals, OBLYXBUTTON,OBLYXCARDHEADER, rather than something like OblyxButton. That matches kotlinx.html's own convention, where tag classes are named DIV,SPAN, BUTTON. A reader who already knows kotlinx.html will find this unsurprising; a reader who does not will find it strange the first time they see it. Either way, reach for the builder function (oblyxButton, lowercase) at the call site. The tag class only shows up as the receiver type of the trailing block, and as the return type if you ever need to name it explicitly.
A complete route
A full Ktor route rendering a real page end to end, using the components covered above:
A signup page rendered from a Ktor route
<oblyx-card>
<oblyx-card-header>Sign up</oblyx-card-header>
<oblyx-card-body>
<oblyx-input label="Email" name="email" type="email" required></oblyx-input>
<oblyx-input label="Name" name="name" required></oblyx-input>
</oblyx-card-body>
<oblyx-card-footer>
<oblyx-button variant="primary">Sign up</oblyx-button>
</oblyx-card-footer>
</oblyx-card>import io.ktor.server.application.Application
import io.ktor.server.html.respondHtml
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.html.HTML
import kotlinx.html.body
import kotlinx.html.head
import kotlinx.html.link
import kotlinx.html.meta
import kotlinx.html.script
import kotlinx.html.title
import org.oblyx.ktor.OblyxButtonVariant
import org.oblyx.ktor.OblyxInputType
import org.oblyx.ktor.installOblyxAssets
import org.oblyx.ktor.oblyxButton
import org.oblyx.ktor.oblyxCard
import org.oblyx.ktor.oblyxCardBody
import org.oblyx.ktor.oblyxCardFooter
import org.oblyx.ktor.oblyxCardHeader
import org.oblyx.ktor.oblyxInput
fun Application.module() {
installOblyxAssets()
routing {
get("/") {
call.respondHtml { signupPage() }
}
}
}
private fun HTML.signupPage() {
head {
meta(charset = "utf-8")
title("Sign up")
link(rel = "stylesheet", href = "/oblyx/oblyx.css")
script(type = "module", src = "/oblyx/oblyx.js") {}
}
body {
oblyxCard {
oblyxCardHeader { +"Sign up" }
oblyxCardBody {
oblyxInput(label = "Email", name = "email", type = OblyxInputType.EMAIL, required = true)
oblyxInput(label = "Name", name = "name", required = true)
}
oblyxCardFooter {
oblyxButton(variant = OblyxButtonVariant.PRIMARY) { +"Sign up" }
}
}
}
}