Java · Kotlin · JDK 26

Fail the build. Never the request.

Thim is an opinionated compile-time HTML renderer. Templates, translations, and page models are compiler inputs. Missing properties, unused copy, dead fragments, CSS, and incomplete locales fail compilation. The request path is generated Java writing UTF-8.

Apache-2.0 Maven Central Spring Framework 7 optional No runtime template engine

Closed world

More safety. More batteries. Less rope.

JTE lets you write Java in the template. JStachio compiles Mustache against a model. Thim competes with both, then refuses more. The language is small on purpose. If the compiler cannot prove a value, a message, or a URL, the application does not compile.

01

Dead code is a compiler error

Unused page-model properties fail the build. Unused fragments can fail the build. Unused catalog keys fail when you own the bundle. A field that nothing prints is leftover surface area, not a feature.

02

Locale is not an add-on

YAML catalogs compile into the renderer. Every locale must carry the same files, keys, and argument types. CLDR plural rules are embedded. lang on <html> comes from the request locale when you omit it.

03

Request time is boring Java

Static HTML is stored as UTF-8 bytes. Dynamic values are encoded for their output context. There is no OGNL, no reflective dispatch, no runtime template lookup, and no leftover engine in the jar.

Familiar HTML

Thymeleaf-shaped templates. A typed page model.

Name the model after the template. home.html resolves to HomePage. Return the model from a controller. Thim generates the renderer.

home.html
<!doctype html>
<html>
<body>
  <h1 th:text="#{home.title(version=${version})}">Home</h1>
  <p th:text="${greeting}">Greeting</p>
  <p th:text="#{home.inbox(unreadCount=${unreadCount})}">Unread</p>
  <li th:each="feature : ${features}">
    <a th:href="@{/feature/{name}(name=${feature.name})}"
       th:text="${feature.name}">Name</a>
  </li>
</body>
</html>
HomePage.kt
data class HomePage(
    val version: String,
    val greeting: String,
    val unreadCount: Int,
    val features: List<Feature>,
)

@GetMapping("/")
fun home() = HomePage(
    version = "0.10.0",
    greeting = "Typed models, compiled HTML.",
    unreadCount = 3,
    features = listOf(
        Feature("Safe", "Checked while you compile."),
        Feature("Small", "Dependency-free Java runtime."),
    ),
)

What the compiler refuses

If it can go wrong at request time, it is a build failure.

Thim is narrower than a general template engine. That is the product. Prepare computed display values on the page model. Do not ask the template to invent types, look up templates, or evaluate an expression language.

  • Missing, extra, or mistyped message keys across locales
  • Unused model properties, fragments, messages, and first-party CSS classes
  • Mutable models, Any/Object, maps, lazy collections
  • JPA entities on a page model
  • Nullable dereference without ?.
  • Unknown Spring routes and unsafe javascript: URLs
  • Dynamic JavaScript, CSS, or event-handler content
  • Nested forms, broken label / ARIA / local anchors
  • OGNL utility objects and runtime template selection
  • Malformed HTML and unsupported output contexts

Batteries included

Translations compile with the page.

Catalogs are a failsafe YAML 1.2 subset: mappings and string scalars only. no, true, and dates stay text. SnakeYAML Engine is a compiler dependency. Request time never parses YAML, looks up a key, or interprets a pattern. The generated renderer already contains the UTF-8 bytes and the locale branch.

en/home.yaml
title: Thim {version}
inbox:
  _plural: unreadCount
  one: One unread message
  other: "{unreadCount} unread messages"
salutation:
  _select: audience
  MEMBER: Welcome back, {name}
  other: Welcome, {name}
nb/home.yaml
title: Thim {version}
inbox:
  _plural: unreadCount
  one: Én ulest melding
  other: "{unreadCount} uleste meldinger"
salutation:
  _select: audience
  MEMBER: Velkommen tilbake, {name}
  other: Velkommen, {name}
Generated request path
switch (messageLocale) {
  case 1 -> switch (PluralRules
      .cardinal("nb", "", unread)) {
    case "one" -> output.raw(STATIC, …);
    default -> {
      output.text(unread);
      output.raw(STATIC, …);
    }
  };
  default -> /* en contract */
}

Integer cardinal rules from Unicode CLDR 49 are embedded for the European languages Thim lists in the README. A _plural catalog in an unsupported language fails compilation rather than guessing. The same catalog is automatically available to backend code through generated, typed factories returning CompiledMessage; runtime string keys and a second message bundle are not needed. Argument-free messages also provide constant *Reference values for annotation APIs, with generated isReference and resolveReference methods at the framework boundary.

Among compile-time engines

JTE and JStachio are flexible. Thim is closed.

Choose JTE if you want Java or Kotlin in the template and an IntelliJ plugin. Choose JStachio if you want Mustache and logic-less views. Choose Thim if you want Thymeleaf-shaped HTML, compiled locale catalogs, and a compiler that treats leftover model fields and leftover copy as defects.

Contract Thim JTE JStachio
Template language Restricted th:* HTML Java or Kotlin in the template Mustache 1.3
Type checking Page model vs template The host language Model bindings
Unused model surface Error in strict mode Parameters are declared in the template Unused fields are allowed
Compiled locale catalogs YAML in, branches out Build it yourself Build it yourself
Locale parity Same keys and arguments in every locale Not a compiler concern Not a compiler concern
Plurals and selects CLDR cardinals, enum selects Host-language if Lambdas or extra models
Expression language None Java / Kotlin Mustache sections and lambdas
Runtime engine None on the request path Optional engine, precompile, hot reload Optional JMustache fallback
Layouts Fixed fragments, inlined at compile time Content blocks and template calls Mustache inheritance
Spring MVC adapter, optional typed routes Integrations First-party Spring support
Opinion Closed world Flexible Logic-less

All three compile templates to Java and escape HTML. Thim does not claim to be the fastest of the three. It claims to reject more invalid programs before they start a JVM.

Runtime

A small Java output API. Readable generated code.

The runtime, generated renderers, Spring adapter, and Gradle plugin are Java. A request creates one buffered HtmlOutput, copies static byte ranges, and encodes escaped values into that buffer. It creates no intermediate escaped strings. Composition is erased when fragments are inlined. Each template jar publishes its registry through ServiceLoader.

runtime

Dependency-free output

HtmlOutput, RenderContext, TrustedUrl, SafeHtml, and CLDR plural tables.

compiler

KSP for Java and Kotlin

Records, beans, and data classes keep their source-level types. Templates can only print scalars.

spring

Return the page model

The MVC adapter recognizes page models and ThimResult. Non-Spring apps call TemplateSet directly.

Get started

One plugin. Conventional directories. Strict by default.

Templates live in src/main/resources/templates. Catalogs live in src/main/resources/i18n. The default model package is <group>.page. Override a directory only for a migration. Requires JDK 26 or newer.

Kotlin module
plugins {
    kotlin("jvm")
    id("no.beint.thim") version "0.10.0"
}

thim {
    generatedPackage.set("your.group.thim.generated")
    modelPackages.set(listOf("your.group.page"))
    generateRoutes.set(true)
}
Java module
plugins {
    java
    id("no.beint.thim") version "0.10.0"
}

thim {
    generatedPackage.set("your.group.thim.generated")
    modelPackages.set(listOf("your.group.page"))
}

Artifacts are on Maven Central. Add mavenCentral() to plugin management and dependency resolution. Use ./gradlew thimCheck --continuous while you edit templates. Full instructions live in the README.