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.36.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.36.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.36.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 = "email", required = RequiredMarker.TEXT, help = true, error = true, invalid = invalid)
"""
<label ${field.label.toHtml()}>Email address</label>
<input ${(Lt.input(type = InputType.EMAIL, state = field.state) + field.control).toHtml()}>
<p ${(Lt.helpText() + field.help).toHtml()}>We send the receipt here.</p>
<p ${(Lt.errorText() + field.error).toHtml()}>Enter a valid address.</p>
"""

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

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.

io.github.markusaugust:lo-tek-kotlinx-html has one extension, lt(…), which puts the attributes from the builder functions on a tag. 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.emailField(invalid: Boolean) {
val field = Lt.field(id = "email", required = RequiredMarker.TEXT, help = true, error = true, invalid = invalid)
label { lt(field.label); +"Email address" }
input { lt(Lt.input(type = InputType.EMAIL, state = field.state), field.control); name = "email" }
p { lt(Lt.helpText(), field.help); +"We send the receipt here." }
p { lt(Lt.errorText(), field.error); +"Enter a valid address." }
}

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.

routing {
staticResources("/webjars", "META-INF/resources/webjars")
get("/application") {
call.respondHtml {
head { link(rel = "stylesheet", href = LotekWebJar.CSS) }
body {
form(action = "/application", method = FormMethod.post) {
emailField(invalid = false)
button { lt(Lt.button()); +"Send" }
}
script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }
}
}
}
}

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 application is correct markup`() {
val out = StringOutput()
templateEngine.render("application.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="email" points to the id "email", 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.36.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/application.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.