Skip to content

Kotlin and Java

LO-TEK for Kotlin gives you three things: the builder functions from Lt, where the allowed values are enums; the same check that lotek check and the editor use, as an ordinary function in your tests; and the CSS and the web components as a WebJar. Everything runs in the JVM or is served by the app. No Node, no npm, no process to start and nothing to install alongside.

build.gradle.kts
dependencies {
implementation("io.github.markusaugust:lo-tek-interfaces:0.40.0")
}

The version is the same as the npm package’s, so a version number means the same LO-TEK in Maven and on npm. But not every npm version goes to Maven Central. If the version above is not on Central, use the newest one that is. The package has one dependency, com.dylibso.chicory:runtime, and requires Java 17 or newer.

The package is also a WebJar: lotek.css, the stylesheet for each component and the JavaScript modules for the browser are under META-INF/resources/webjars/lo-tek/0.40.0/, with the same paths as in the npm package. Spring Boot and Servlet containers serve that folder without setup. In Ktor it is one line:

routing {
staticResources("/webjars", "META-INF/resources/webjars")
}

LotekWebJar has the paths, with the version in them, so the template never points to a different version than the one the package has:

"""
<link rel="stylesheet" href="${LotekWebJar.CSS}">
<script type="module">
import { defineLt } from "${LotekWebJar.REGISTER}"
defineLt()
</script>
"""

An address from the component pages keeps its path and only changes the beginning. This one:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css">

becomes ${LotekWebJar.ROOT}/src/components/css/button/button.css. What is only for Node or React is not included.

Lt has the same builder functions and options as lt in TypeScript. Each gives the attributes you put on the element:

import no.lotek.*
val save = Lt.button(variant = ButtonVariant.SECONDARY)
"""<button ${save.toHtml()}>Save draft</button>"""
// <button class="lt-button" data-variant="secondary">Save draft</button>

The allowed values are enums, so ButtonVariant.SECUNDARY is a compile error, and the IDE shows what exists. Values that recur have one shared name: Size, FieldState and RequiredMarker are used by every builder function that has them. Every enum has value, the text that ends up in the HTML, and of(text), which gives the value, or null for a text from outside.

The result is an Attributes, which is an ordinary Map<String, String>. It can be given to any template engine. Without a template engine, toHtml() writes it out with the values escaped, and + combines two sets.

The builder functions that give several sets give a data class with one field per set. Lt.field wires the label, control, help text and error text:

val field = Lt.field(id = "fixer", required = RequiredMarker.TEXT, help = true, error = true, invalid = invalid)
"""
<label ${field.label.toHtml()}>Fixer's address</label>
<input ${(Lt.input(type = InputType.EMAIL) + field.control).toHtml()}>
<p ${(Lt.helpText() + field.help).toHtml()}>The board writes to the fixer, never to the crew.</p>
<p ${(Lt.errorText() + field.error).toHtml()}>Enter the fixer's address, for example fixer@relay.example.</p>
"""

A flag such as hidden has the empty text as its value, and is written as the name alone.

The id is required, and an empty one throws. Make it from a key in the data, never from the loop index or a counter, so it is unique on the page and the same on every render: a morph recognises an element by its id, and replaces one whose id changed, along with what the user typed. See Ids that survive an update.

The parts a template writes itself, such as the heading in a card or an option in a toggle group, have their classes on the builder function, with the same names as in TypeScript. Each is a set with the class, so it goes where every other answer goes. The exception is a part the answer already gives, such as the header of Lt.dialog: there the set in the answer is the way, and the constant TypeScript also has is left out.

"""
<div ${Lt.card().toHtml()}>
<h3 ${Lt.card.title.toHtml()}>Job 222-0481</h3>
</div>
"""

The builder functions are generated from the manifest LO-TEK writes from the TypeScript code, and the few where the options interact, such as field and tabs, are written by hand. All are tested against more than a thousand build cases with the answer from TypeScript. If one builder function gives one attribute differently, the package is not released.

The builder functions are static methods on Lt, and the options can be left out from the back:

import no.lotek.*;
Attributes save = Lt.button(ButtonVariant.SECONDARY);
FieldParts field = Lt.field("fixer");
ButtonVariant variant = ButtonVariant.of(request.getParameter("variant"));

An option in the middle of the list needs the ones before it, as null when they are left out. JavaCallTest compiles these calls in Java on every build.

The documentation on each builder function, the options with their defaults and a link to the component page, comes from the same manifest, so IntelliJ shows it in Quick Documentation on Lt.button(…) from the sources jar.

io.github.markusaugust:lo-tek-kotlinx-html has one extension, lt(…), which puts the attributes from the builder functions on a tag, and one function per web component, such as ltField { … } for <lt-field>, since kotlinx.html knows only the elements in HTML. It is a separate package, so the main package does not bring kotlinx.html to those who do not use it.

import kotlinx.html.*
import no.lotek.*
import no.lotek.InputType
import no.lotek.html.lt
fun FlowContent.fixerField(fixer: String, invalid: Boolean) {
val field = Lt.field(id = "fixer", required = RequiredMarker.TEXT, help = true, error = true, invalid = invalid)
label { lt(field.label); +"Fixer's address" }
input { lt(Lt.input(type = InputType.EMAIL), field.control); name = "fixer"; required = true; value = fixer }
p { lt(Lt.helpText(), field.help); +"The board writes to the fixer, never to the crew." }
p { lt(Lt.errorText(), field.error); +"Enter the fixer's address, for example fixer@relay.example." }
}

Every component page has a kotlinx.html tab in this form. RecipesTest compiles every one, renders it, and compares it with the HTML recipe on the same page.

Several sets are applied in order, and a later one wins, like {...a} {...b} in JSX. The classes are added to those the tag has, so div(classes = "my-own") { lt(Lt.card()) } gives both. InputType is imported on its own, since kotlinx.html has one with the same name.

The whole round trip, with the form from the error summary page. GET /jobs renders the form. POST /jobs checks the submission, and a failed one gets the form back with status 422, with what was typed and the summary filled in, and the component moves focus to the summary when the page arrives. A valid one is saved, and the browser is sent to GET /jobs?ordered, so a reload does not post it again, and that page says the job was ordered. The only script is the registration, once in the page.

// build.gradle.kts, next to the Ktor 3 server and lo-tek-kotlinx-html
dependencies {
implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
}
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.html.respondHtml
import io.ktor.server.http.content.staticResources
import io.ktor.server.request.receiveParameters
import io.ktor.server.response.respondRedirect
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import kotlinx.html.*
import no.lotek.*
import no.lotek.InputType
import no.lotek.html.lt
import no.lotek.html.ltErrorSummary
data class Fault(val field: String, val message: String)
// The faults for what was submitted. Empty when the job can be ordered.
fun faultsIn(fixer: String): List<Fault> =
if (fixer.contains("@")) emptyList()
else listOf(Fault("fixer", "Enter the fixer's address with an @"))
fun FlowContent.orderForm(fixer: String = "", faults: List<Fault> = emptyList()) {
val summary = Lt.errorSummary(count = faults.size, id = "errors")
val field = Lt.field(id = "fixer", invalid = faults.any { it.field == "fixer" })
form(action = "/jobs", method = FormMethod.post) {
// The server checks the address. The browser's own check of
// type="email" would stop the submission before it got there.
attributes["novalidate"] = ""
ltErrorSummary {
lt(summary.host)
h2 {
lt(summary.title)
if (faults.isNotEmpty()) +(if (faults.size == 1) "The form has one error" else "The form has ${faults.size} errors")
}
ul {
lt(Lt.list())
for (fault in faults) li { a(href = "#${fault.field}") { +fault.message } }
}
}
label { lt(field.label); +"Fixer's address" }
input {
lt(Lt.input(type = InputType.EMAIL), field.control)
name = "fixer"
if (fixer.isNotEmpty()) value = fixer
}
button { lt(Lt.button()); +"Order the job" }
}
}
fun HTML.jobsPage(ordered: Boolean = false, content: FlowContent.() -> Unit) {
attributes["lang"] = "en"
head {
title("Order a job")
link(rel = "stylesheet", href = LotekWebJar.CSS)
}
body {
if (ordered) div { lt(Lt.alert(color = AlertColor.SUCCESS)); p { +"The job has been ordered." } }
content()
script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
}
fun Application.jobs() {
routing {
staticResources("/webjars", "META-INF/resources/webjars")
get("/jobs") {
val ordered = call.request.queryParameters.contains("ordered")
call.respondHtml { jobsPage(ordered) { orderForm() } }
}
post("/jobs") {
val fixer = call.receiveParameters()["fixer"].orEmpty().trim()
val faults = faultsIn(fixer)
if (faults.isEmpty()) {
// Save the job here.
call.respondRedirect("/jobs?ordered")
} else {
call.respondHtml(HttpStatusCode.UnprocessableEntity) { jobsPage { orderForm(fixer, faults) } }
}
}
}
}

The forms on the component pages post, so a real app needs the protection against cross-site request forgery it already has. kotlinx.html writes no token by itself, as a template engine with a security integration might. With Spring Security, pass the CsrfToken into the function and write it as a hidden field in each form, hiddenInput(name = csrf.parameterName) { value = csrf.token }. A script that posts with fetch cannot see the Kotlin value, so render it into the page as well, meta(name = "_csrf", content = csrf.token) and meta(name = "_csrf_header", content = csrf.headerName) in head, and send it as that header from there, as Spring Security’s own documentation does. Ktor’s CSRF plugin, io.ktor:ktor-server-csrf, checks the Origin header rather than a token, so the forms need nothing written in them: install(CSRF) { originMatchesHost() }, or allowOrigin("https://…") behind a proxy that changes the host. Leave out checkHeader, which requires a header a plain form post never sends.

A template cannot call Lt for every value, and then the check is what keeps watch. Write the markup as on the component pages, and check what the template gives in a test. With JTE:

@Test
fun `the order form is correct markup`() {
val out = StringOutput()
templateEngine.render("order.kte", mapOf("invalid" to true), out)
assertLotek(out.toString())
}

The same goes for Thymeleaf, Freemarker and Pebble: render the template to a text, and give it to assertLotek. The check also understands the template syntax in the template file itself, such as ${…}, th:… and @if, so the editor can tell you while you type.

assertLotek checks the HTML the server sends, and fails with the findings as its message:

@Test
fun `the form is wired correctly`() {
assertLotek(app.render("/form"))
}
LO-TEK found 2 findings:
12:30: error: for="fixer" points at the id "fixer", which does not exist on the page. … [id-not-found]
18:9: warning: The class "lt-buton" does not exist in LO-TEK. Did you mean lt-button? [unknown-class]

It throws an AssertionError, which JUnit, Kotest and TestNG all understand. By default a whole page is checked: that every id that is pointed to exists, and that every field is wired. If the HTML is a piece of a page, such as a component, assertLotek(html, fragment = true) checks only the vocabulary.

It works the same for everything that makes HTML on the JVM: strings, kotlinx.html, Thymeleaf, JTE, Ktor and Spring. The check reads the HTML that comes out, not the code that makes it.

If you need the findings yourself, Lotek.diagnosePage(html) and Lotek.diagnoseMarkup(html) give them as a list. Each finding has start and end, line and column, severity, rule, message, link and sometimes a fix. The positions are indices in a Kotlin String, so html.substring(finding.start, finding.end) gives exactly the text the finding is about, also with “æøå” and emoji in front.

A finding you want to keep is suppressed with a comment above the tag:

<!-- lotek-ignore-next unknown-class -->
<div class="lt-experiment">…</div>

Without a rule name, the comment applies to all the rules for the next tag.

With Playwright for Java, the page is checked as the browser has rendered it, with the markup the web components added and the stylesheets it loaded. The package does not depend on Playwright: assertLotekRendered takes a function that runs a script on the page.

@Test
fun `the form is right as it is shown`() {
page.navigate("http://localhost:8080/form")
assertLotekRendered { script -> page.evaluate(script) }
}

It fails on what assertLotek fails on, and in addition on a class no stylesheet styles and a value without a rule. If you have the stylesheets as text, assertLotek(html, css = listOf(stylesheet)) and Lotek.diagnosePage(html, css) give the same check without a browser.

If the project has taken over components, diagnoseMarkup and diagnosePage take the manifest as text in manifest, and then check against the same as the Gradle plugin and the editor. Without the option, the manifest in the package applies. assertLotek and assertLotekRendered do not take it yet.

val manifest = File("build/lotek/manifest.json").readText()
assertEquals(emptyList(), Lotek.diagnosePage(html, manifest = manifest))

The file is written by lotekManifest, which is run by assemble and not by test. If the tests are to read it, they must wait for the task: tasks.test { dependsOn("lotekManifest") }.

Lotek.buildTheme(recipe) builds the same colour theme as lotek theme, from the JSON text of a recipe file, and Lotek.inspectTheme(css) checks a theme you have written yourself, like lotek check-theme. The recipe has the nine colours, typography and shape:

val theme = Lotek.buildTheme("""{"accent": "#7c3aed", "danger": "#b3261e"}""")
File("build/lotek/theme.css").writeText(theme.css)

The result has light and dark, each --lt-color-* token with its value, violations and css. The promises hold by construction, so violations is empty for valid colours, but check it before you write the file: lotek theme refuses to write when it is not empty, while buildTheme returns the theme with the list, as in TypeScript. A key that does not exist, and a recipe the core rejects, throw IllegalArgumentException with the explanation. inspectTheme gives problems with selector and message, and the numbers blocks, declarations and promises, which say how much was actually read. The recipe is read the way lotek theme reads it, and both give the answer the core gives in TypeScript, tested against the same expected results.

The template files themselves can be checked as part of the build, without a test that renders them. The plugin runs the same command line as lotek check and lotek theme, in the Gradle process:

build.gradle.kts
plugins {
id("io.github.markusaugust.lo-tek") version "0.40.0"
}
lotek {
templates.from(fileTree("src/main/jte"))
themeRecipe = file("lotek.theme.json")
}
TaskDoes
lotekCheckchecks templates like lotek check, and fails with the findings. Run by check.
lotekThemebuilds the theme from themeRecipe to themeOutput, by default build/lotek/theme.css
lotekManifestwrites the manifest the project is checked against to build/lotek/manifest.json when manifests has fragments, and deletes the file when it has none. Run by assemble.
> Task :lotekCheck FAILED
The markup does not match LO-TEK:
src/main/jte/order.jte:14:18: warning: The class "lt-buton" does not exist in LO-TEK. Did you mean lt-button?

rendered = true checks the files as whole pages, with the wiring, like --rendered. If you have taken over a component with lotek takeover, you put the fragment in manifests, so the markup for the copy is checked too, and the copy is included in build/lotek/manifest.json:

lotek {
templates.from(fileTree("src/main/jte"))
manifests.from("src/main/lotek/button/lotek-manifest.json")
}

The plugin is on Maven Central together with the package, with the same version. Gradle finds it there without a separate pluginManagement block, since the Gradle Plugin Portal forwards to Maven Central for what it does not have itself. The tasks work with the configuration cache, and are up to date when neither the templates nor the fragments have changed.

The check is written in Rust and built to one WebAssembly module. The same module runs in Node, in VS Code and here. On the JVM it is run by Chicory, a WebAssembly runtime written in pure Java. The module was compiled to JVM bytecode when the package was built, so it runs with the JIT and starts in a fraction of a second. A call borrows an instance from a pool and returns it afterwards, so tests that run at the same time do not wait for each other.

The plugin LO-TEK Interfaces runs the same check while you type, also in HTML in a Kotlin string marked with @Language("HTML") or // language=HTML. See The editor.

With the builder functions it does four more things in Kotlin:

  • completion shows the description and the default of each option next to it;
  • it warns about a set on an element its part cannot be, like label { lt(field.control) }, and moves it where exactly one element can take it;
  • it warns about ButtonVariant.of(value)!!, which throws without saying what the value was, about an id from the loop index, a random value or the same text in every row, and about Lt.field(error = true) without field.error;
  • ltfield, lttabs and one for each of the other web components, at the top level of a file, insert the Kotlin recipe from the component page, with the ids left for you to fill in.