- Kotlin 100%
|
All checks were successful
Publish Package (kmx) TeamCity build finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| editors/textmate | ||
| gradle | ||
| idea-plugin | ||
| kmx-compiler | ||
| kmx-gradle-plugin | ||
| kmx-runtime | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle.kts | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle.kts | ||
KMX
KMX is a template engine for Kotlin. A .kmx file is XML markup with Kotlin expressions in it, the way JSX is HTML with JavaScript. The compiler turns each file into Kotlin source, so kotlinc type-checks every expression, and rendering builds a tree that is always well-formed XML.
import com.example.Order
import com.example.ui.Button
props: Order
val total = items.sumOf { it.price * it.qty }
<html lang="ko">
<body>
<p>{customer.nickname ?: customer.name}, your order #{id} is confirmed.</p>
<ul>
@for (item in items) {
<li>{item.name} x{item.qty}</li>
} empty {
<li>No items</li>
}
</ul>
{coupon?.let { <p>Coupon: {it.code}</p> }}
<Button href="https://shop.example.com/orders/{id}">Total {total}</Button>
</body>
</html>
val xml: String = OrderConfirmed.render(order).toXml()
KMX only renders templates. Mail concerns such as subjects, locales, plain-text bodies and layouts belong to the application or to a framework on top, such as Quill.
Modules
Each module works without the ones after it.
| Module | What it is | Use it alone to |
|---|---|---|
kmx-runtime |
Node tree, XML writer, builder DSL, KmxTemplate, RenderScope |
Build XML from Kotlin code with escaping and well-formedness guaranteed |
kmx-compiler |
Parser and code generator, plus the kmxc command |
Compile .kmx files from another build tool or a script |
kmx-gradle-plugin |
xyz.nillpoe.kmx: compiles src/main/kmx before compileKotlin |
Use templates in a Gradle project |
idea-plugin |
IntelliJ IDEA plugin, a separate Gradle build | Edit templates with highlighting, errors, completion and navigation |
editors/textmate |
TextMate grammar, also a VS Code extension folder | Highlight templates in any editor that reads TextMate grammars |
Setup
KMX publishes to the Forgejo package registry at https://forgejo.verchase.com/api/packages/nillpoe/maven. Add it to the plugin and dependency repositories, then apply the plugin next to the Kotlin JVM plugin:
plugins {
kotlin("jvm") version "2.4.10"
id("xyz.nillpoe.kmx") version "0.3.0"
}
The plugin compiles every .kmx file under src/main/kmx into build/generated/kmx/main/kotlin, adds that directory to the main Kotlin source set, and adds kmx-runtime to api (or implementation without java-library).
| Setting | Meaning | Default |
|---|---|---|
directory |
Root of the .kmx files; subdirectories become packages |
src/main/kmx |
internal |
Generates internal declarations instead of public |
false |
addRuntimeDependency |
Adds kmx-runtime |
true |
Without Gradle, run the compiler directly: kmxc <source directory> <output directory> [--internal], or call KmxCompiler.compileDirectory / KmxCompiler.compile from code. KmxOptions overrides the package, the object name and the visibility, and adds default imports every file gets, the way a framework on top of KMX exposes its helpers. KmxCompiler.header reads a file's imports, props and KDoc without generating code. KmxCompiler.elements lists every element and component call with its attributes and position, for tools that check markup, such as class names. KmxOutput.mappings tells where each piece of the template's Kotlin landed in the generated code, for editors and other tools.
Files
A file has a Kotlin header, then markup. The markup starts at the first line that begins with a tag (<) or a directive (@if, @for, @when, @{).
The header holds, in any order:
| Line | Meaning |
|---|---|
package a.b |
Package of the generated code. Default: the file's directory, so src/main/kmx/mail/welcome.kmx is in mail |
import ... |
Copied to the generated file |
props: Order |
The template renders an Order, whose members the markup reads directly ({id}, {items}) |
props(name: String, vip: Boolean = false) |
The template renders a generated Props class with these properties |
component Button(href: String, children: Node) |
The file is a component, not a template |
/** ... */ before props or component |
KDoc, copied onto the generated object or function and read by KmxCompiler.header as documentation |
| Anything else | Kotlin statements that run before the markup, such as val total = ... or local functions |
Without a props line the template renders Unit.
A template compiles to an object named after the file in PascalCase (order_confirmed.kmx → OrderConfirmed) that implements KmxTemplate<P>:
interface KmxTemplate<P> {
val source: String
fun render(props: P, scope: RenderScope = RenderScope.EMPTY): Node
}
props(...) templates also get render(name, vip, scope). props and scope are the names of the render parameters, so a template can write props.id or scope[KEY], and props cannot use those names.
Markup
Text and expressions
| Syntax | Result |
|---|---|
<p>Hi {name}</p> |
Text, escaped. The expression must be a String, Number, Char, Boolean, Node?, or Iterable<Node> |
{user.nickname ?: user.name} |
A String? does not compile; decide what null prints |
{coupon?.let { <p>{it.code}</p> }} |
A null node prints nothing |
{tags.map { <li>{it}</li> }} |
Markup is also a Kotlin expression of type Node |
{Markup.trusted(xml)} |
XML inserted as is, after a well-formedness check |
& < > " ' © © |
Character references in text and attribute values |
{"{"}, {"}"}, @@ |
A literal brace or @ |
{/* note */} |
A template comment, not written |
<!-- note --> |
An XML comment, written |
<>...</> |
A fragment: several nodes without a wrapper |
Whitespace follows the JSX rules. Text on one line stays as written. Where text spans lines, each line is trimmed at the line breaks, blank lines disappear, and the remaining lines join with one space. Inside a directive's braces, spaces between a brace and a tag, an expression or a directive are dropped, so { <b>Paid</b> } writes no spaces, while text keeps its spaces, so separator {, } writes , . Write {" "} for a space the rules would remove, such as a block holding only a space.
Attributes
| Syntax | Result |
|---|---|
class="total" |
Literal |
colspan={columns} |
Expression: String?, Number? or Boolean?. null and false leave the attribute out; true writes name="name" |
href="https://{host}/orders/{id}" |
Interpolation. {{ and }} are literal braces |
checked |
Same as checked={true} |
{...attributes} |
Every entry of a Map<String, String?> |
Directives
@if (items.isEmpty()) {
<p>Nothing yet.</p>
} else if (items.size > 10) {
<p>Top ten shown.</p>
} else {
<ItemTable items={items} />
}
@for ((index, item) in items.withIndex()) {
<tr class={if (index % 2 == 0) "odd" else "even"}><td>{item.name}</td></tr>
} separator {
<tr class="divider" />
} empty {
<tr><td>No items</td></tr>
}
@when (status) {
Status.PAID -> <b>Paid</b>
Status.CANCELED -> { <b>Canceled</b> }
else -> <i>Pending</i>
}
@{
val discount = coupon?.rate ?: 0.0
}
The parentheses hold Kotlin, copied as written: smart casts, destructuring and exhaustive when checks work as in Kotlin. separator and empty are optional. @{ } runs statements without output; the values it declares are visible to the markup after it.
@ starts a directive only as @if (, @for (, @when, or @{, so addresses such as help@example.com stay text.
Components
A tag starting with an uppercase letter calls a function: <Button href={url} wide>Pay</Button> compiles to Button(href = url, wide = true, children = <the body>). Any Kotlin function returning Node works. A component file generates one:
component Button(href: String, wide: Boolean = false, children: Node)
<table role="presentation" width={if (wide) "100%" else null}>
<tr><td class="btn"><a href={href}>{children}</a></td></tr>
</table>
It compiles to fun RenderScope.Button(href: String, wide: Boolean = false, children: Node): Node. Component attributes must be Kotlin parameter names; pass other Node parameters as attributes: <Card header={<h2>Shipping</h2>}>.
A parameter may be named after a Kotlin keyword, so a component can take class or for like an element does. KMX declares it in backticks, and Kotlin code reads it that way:
component Note(class: String? = null, children: Node)
<p class={`class`}>{children}</p>
<Note class="muted"> calls Note(`class` = "muted", ...).
Markup inside Kotlin
Inside { } and header code, < starts markup where an expression starts: after an operator, (, ,, {, ->, ), or return, else, in, do, try, throw. After a name or a literal it stays a comparison or type arguments, so a < b, i<n and emptyList<Node>() keep their Kotlin meaning. Write comparisons after ) with a space: (a) < b.
Render scope
RenderScope passes values to every template and component besides the props, such as a locale. Templates run with the scope as an implicit receiver, so extension functions on it read like built-ins:
val CURRENCY = RenderKey<String>("currency")
fun RenderScope.money(amount: Long): String = "$amount ${this[CURRENCY]}"
OrderConfirmed.render(order, RenderScope.of(CURRENCY, "KRW"))
<td>{money(item.price)}</td>
Output
node.toXml() // <p>Hi</p>
node.toXml(XmlFormat(declaration = true)) // with <?xml ...?>; requires one root element
node.toXml(XmlFormat(selfClosing = SelfClosing.only(setOf("br", "img"))))
Text and attribute values are escaped, element and attribute names are validated, and characters XML cannot hold are rejected. SelfClosing.only writes <td></td> instead of <td/> for XHTML that HTML parsers read.
Source locations
Every element a template writes knows where it was written: Element.location is a SourceLocation with the template's path, line and column, such as mail/order_confirmed.kmx:12:5. The elements a component returns take the location of the tag that called it, unless the component is a template file itself and gave them their own. Elements built in Kotlin without a template have none.
Locations are not written and do not take part in equals. They let tools that inspect a rendered tree, such as a linter, point at the template line behind a problem. Kotlin code can pass them to the builders too: element("p", location) { ... } and insert(node, location).
Errors
The compiler reports syntax errors with the file, line and column:
e: src/main/kmx/mail/order_confirmed.kmx:12:5 Expected `</li>` to close `<li>` from line 11.
Type errors come from kotlinc and point at the generated file. Each generated statement ends with a // file.kmx:line comment naming the template line it came from.
Editor support
IntelliJ IDEA
The plugin in idea-plugin supports IntelliJ IDEA 2026.2 and later with the Kotlin plugin in K2 mode. Install idea-plugin/build/distributions/kmx-intellij-<version>.zip with Settings → Plugins → ⚙ → Install Plugin from Disk.
- Highlighting of tags, components, attributes, entities and directives, and of the Kotlin parts with Kotlin's own lexer.
- Template syntax errors where they are.
- Kotlin errors and warnings in the template: the plugin compiles the template as the build does and analyzes the generated code in the template's module, then shows each diagnostic on the template text it came from.
- Completion of locals, props members, header values, builder and render scope members, and members after
.. - Go to Declaration, into Kotlin sources and back into the template for props, header values and loop variables.
- Brace matching, and
{/* */}for Comment with Block Comment. - In Quill modules with a
src/main/quill/tailwind.properties, completion ofclassnames with the CSS each one inlines, and errors on classes the theme does not know. The module's ownquill-tailwindlibrary resolves them, so the editor agrees with the build.
The plugin reads the package, name and default imports of each template from the file the build generated for it, so run the build once after adding a template. Frameworks such as Quill choose those, and the editor then analyzes the template exactly as they compile it.
Other editors
editors/textmate holds a TextMate grammar. In IntelliJ-based IDEs without the plugin, add the folder in Settings → Editor → TextMate Bundles. In VS Code, copy it to ~/.vscode/extensions/kmx. It highlights the markup and hands {...}, directive conditions and the header to the editor's Kotlin grammar; it does not report errors.
Building
./gradlew build
kmx-gradle-plugin's tests publish all modules to build/testing-repository and build sample projects against them.
The IntelliJ plugin is a separate build that includes this one for kmx-compiler:
cd idea-plugin
./gradlew buildPlugin
Put ideaPath=<IntelliJ IDEA installation> in idea-plugin/local.properties to build and test against an installed IDE instead of a downloaded one. ./gradlew runIde -PopenProject=<path> starts a sandbox IDE with the plugin.
Publishing
./gradlew publishAllPublicationsToForgejoRepository -PforgejoUsername=... -PforgejoPassword=...