JSX-style XML templates with Kotlin expressions, compiled to type-checked Kotlin.
Find a file
NillPoe 665367c6d0
All checks were successful
Publish Package (kmx) TeamCity build finished
chore: release 0.4.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 05:40:21 +09:00
editors/textmate feat: add IntelliJ IDEA plugin and TextMate grammar 2026-09-29 17:19:08 +09:00
gradle feat: add kmx template engine 2026-09-29 16:28:38 +09:00
idea-plugin feat(idea-plugin): complete and check Quill Tailwind classes 2026-09-30 05:40:21 +09:00
kmx-compiler feat: accept keyword parameters and list elements for tools 2026-09-30 05:19:31 +09:00
kmx-gradle-plugin feat: record source locations of elements and keep KDoc 2026-09-29 18:20:52 +09:00
kmx-runtime feat: record source locations of elements and keep KDoc 2026-09-29 18:20:52 +09:00
.gitattributes feat: add kmx template engine 2026-09-29 16:28:38 +09:00
.gitignore feat: add kmx template engine 2026-09-29 16:28:38 +09:00
build.gradle.kts feat: add kmx template engine 2026-09-29 16:28:38 +09:00
gradle.properties chore: release 0.4.1 2026-09-30 05:40:21 +09:00
gradlew build(idea-plugin): release the plugin under the KMX version 2026-09-29 17:24:44 +09:00
gradlew.bat feat: add kmx template engine 2026-09-29 16:28:38 +09:00
LICENSE feat: add kmx template engine 2026-09-29 16:28:38 +09:00
README.md feat(idea-plugin): complete and check Quill Tailwind classes 2026-09-30 05:40:21 +09:00
settings.gradle.kts feat: add kmx template engine 2026-09-29 16:28:38 +09:00

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
&amp; &lt; &gt; &quot; &apos; &nbsp; &#169; &#xA9; 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 of class names with the CSS each one inlines, and errors on classes the theme does not know. The module's own quill-tailwind library 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=...