Navigation

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

Save
HTMLKotlin
<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:

A closed value set becomes an enum

oblyx-input's "type" attribute has a fixed set of values.

HTMLKotlin
<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

HTMLKotlin
<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

Save Save
HTMLKotlin
<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

Sign up Sign up
HTMLKotlin
<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.

Fragment content, no enclosing card. Sign up
HTMLKotlin
<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.

Sign up
HTMLKotlin
<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

Sign up Sign up
HTMLKotlin
<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" }
      }
    }
  }
}