Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ Thim optimizes for four properties:

## Compilation

1. The Gradle plugin tracks HTML, message bundles and model sources.
1. The Gradle plugin tracks HTML, strict YAML message catalogs and model sources.
2. KSP resolves a page-model class from the template filename and configured model packages.
3. The compiler links fixed layouts and fragments, then validates properties, nullability, localized messages and supported directives.
3. The compiler links fixed layouts and fragments, then validates properties, nullability, locale/key/argument parity, plural and select rules, and supported directives.
4. It emits readable Java renderers and one package-local resource containing static UTF-8 content.
5. Each template jar publishes its generated registry through Java's service loader.

Kotlin modules use the KSP Gradle integration. Java modules run KSP2 directly against Java sources, including records and bean accessors. Both paths call the same compiler and generate the same runtime code.

Message catalogs use a failsafe YAML 1.2 subset containing only mappings and string scalars. Locale directories, filenames and nested mappings provide structure without repeating dotted keys. Parsing and validation are build-time work; generated branches contain the translated UTF-8 bytes and locale decisions.

## Runtime

The runtime, generated renderers, Spring adapter and Gradle plugin are Java. A request creates one buffered `HtmlOutput`, copies static byte ranges and encodes escaped dynamic values directly into that buffer. It creates no intermediate escaped strings and performs no character conversion for static content.
Expand Down
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Apply the plugin after the Kotlin JVM plugin in Kotlin modules:
```kotlin
plugins {
kotlin("jvm")
id("no.beint.thim") version "0.4.20"
id("no.beint.thim") version "0.5.0"
}
```

Expand All @@ -54,7 +54,7 @@ Java modules need only the Java and Thim plugins:
```kotlin
plugins {
java
id("no.beint.thim") version "0.4.20"
id("no.beint.thim") version "0.5.0"
}
```

Expand All @@ -63,7 +63,9 @@ The plugin supplies the runtime, compiler and Spring adapter and tracks template
```kotlin
thim {
templates.set(layout.projectDirectory.dir("src/main/resources/templates"))
messages.set(layout.projectDirectory.dir("src/main/resources"))
messages.set(layout.projectDirectory.dir("src/main/resources/i18n"))
defaultLocale.set("en")
supportedLocales.set(listOf("en", "nb"))
generatedPackage.set("your.group.your_module.thim.generated")
registryName.set("ThimTemplates")
modelPackages.set(listOf("no.example.page"))
Expand Down Expand Up @@ -91,6 +93,51 @@ Use `thimCheck` for fast template validation during development:

`thimCheck` runs the same validation as normal compilation. Java modules also write a report to `build/reports/thim/check.json`.

## Message catalogs

Thim compiles localized YAML catalogs into the generated renderer. SnakeYAML Engine is a compiler dependency only; parsing, key lookup and pattern interpretation never happen at request time.

The directory name is a canonical BCP 47 language tag. The relative YAML filename and nested mappings form the message namespace:

```text
src/main/resources/i18n/
├── en/
│ └── home.yaml
└── nb/
└── home.yaml
```

```yaml
# en/home.yaml
title: Thim {version}
introduction: |-
Compile templates and translations together.
Ship no runtime template engine.
inbox:
_plural: unreadCount
one: One unread message
other: "{unreadCount} unread messages"
salutation:
_select: audience
MEMBER: Welcome back, {name}
other: Welcome, {name}
```

Use named model properties in the template:

```html
<title th:text="#{home.title(version=${version})}">Thim</title>
<p th:text="#{home.inbox(unreadCount=${unreadCount})}">Unread messages</p>
```

`_plural` accepts the locale's reachable subset of `zero`, `one`, `two`, `few`, `many` and the required `other` category. Its argument must be a non-null integral property. `_select` requires a non-null string or enum and also requires `other`; enum variants must name real enum constants. Selections can be nested. All interpolated values are HTML-escaped; catalogs cannot produce raw HTML. Write `{{` or `}}` for a literal brace.

Every configured locale must contain exactly the same message keys and argument contracts. The default locale defines the contract. Missing translations, extra keys, misspelled placeholders, incompatible argument types and unused messages (when enabled) fail compilation. At runtime Thim chooses an exact configured language tag, then a configured language-only tag, then the default locale.

Catalogs use a deliberately small YAML 1.2 profile: mappings and string scalars only. The failsafe schema means plain `no`, `true`, `12` and `2026-08-08` remain text. Duplicate keys, tags, anchors, aliases, sequences and multiple documents are rejected. Block scalars are supported for multiline copy. Only the `.yaml` extension is accepted.

Integer cardinal rules derived from [Unicode CLDR 49](https://unicode.org/cldr/charts/49/supplemental/language_plural_rules.html) are embedded for `af`, `bg`, `bs`, `ca`, `cs`, `cy`, `da`, `de`, `el`, `en`, `eo`, `es`, `et`, `eu`, `fi`, `fo`, `fr`, `ga`, `gd`, `gl`, `hr`, `hu`, `is`, `it`, `lt`, `lv`, `nb`, `nl`, `nn`, `no`, `pl`, `pt`, `ro`, `sk`, `sl`, `sq`, `sr`, `sv` and `sw`. A catalog that uses `_plural` with another language fails compilation rather than guessing.

## Typed controller routes

Kotlin applications can opt into controller-side route builders with `generateRoutes.set(true)`. The generated object is placed beside the template registry and replaces a `Templates` suffix with `Routes`: `WebAppTemplates` produces `WebAppRoutes`. Set `routesName` to override it.
Expand Down Expand Up @@ -135,7 +182,7 @@ fun select(): ThimResult =

Existing handlers declared as Kotlin `Any` or Java `Object` remain supported, but `ThimResult` documents mixed page/redirect outcomes more clearly.

Artifacts are published through `https://maven.pkg.github.com/beint-no/thim`. Add that repository to `pluginManagement` and dependency resolution.
Artifacts and the Gradle plugin marker are published to Maven Central. Add `mavenCentral()` to `pluginManagement` and dependency resolution.

## Template syntax

Expand All @@ -149,7 +196,7 @@ Thim accepts:
- property, message, static URL and quoted-literal values on ordinary `th:*` attributes
- `no.beint.thim.TrustedUrl` properties on URL attributes such as `th:href`, `th:src` and `th:action`
- conditional HTML boolean attributes
- literal `#{message}` expressions with typed arguments
- literal `#{message(argument=${property})}` expressions with typed, named arguments
- `${#locale.language}` for a language attribute

Every dynamic value is encoded for its output context. Use static `@{...}` expressions or `TrustedUrl` for URLs, and use `SafeHtml` only with `th:utext`. Dynamic JavaScript, CSS and event-handler content is rejected.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ plugins {

allprojects {
group = "no.beint.thim"
version = "0.4.20"
version = "0.5.0"
}

subprojects {
Expand Down
9 changes: 9 additions & 0 deletions compiler/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,13 @@ tasks.withType<KotlinJvmCompile>().configureEach {

dependencies {
implementation("com.google.devtools.ksp:symbol-processing-api:2.3.10")
implementation("org.snakeyaml:snakeyaml-engine:3.1.1")

testImplementation(platform("org.junit:junit-bom:6.0.3"))
testImplementation(kotlin("test-junit5"))
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.test {
useJUnitPlatform()
}
16 changes: 12 additions & 4 deletions compiler/src/main/kotlin/no/beint/thim/compiler/Expressions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ internal data class PathSegment(val name: String, val safe: Boolean)

internal data class PathExpression(val segments: List<PathSegment>)

internal data class MessageExpression(val key: String, val arguments: List<PathExpression>)
internal data class MessageExpression(val key: String, val arguments: Map<String, PathExpression>)

internal sealed interface UrlArgument

Expand Down Expand Up @@ -76,13 +76,20 @@ internal object Expressions {
val opening = body.indexOf('(')
if (opening == -1) {
require(body.matches(keyPattern)) { "$context: invalid message key '$body'" }
return MessageExpression(body, emptyList())
return MessageExpression(body, emptyMap())
}
require(body.endsWith(')')) { "$context: invalid message expression '$value'" }
val key = body.substring(0, opening).trim()
require(key.matches(keyPattern)) { "$context: invalid message key '$key'" }
val arguments = splitArguments(body.substring(opening + 1, body.length - 1), context)
.map { path(it, context) }
val arguments = linkedMapOf<String, PathExpression>()
splitArguments(body.substring(opening + 1, body.length - 1), context).forEach { argument ->
val equals = argument.indexOf('=')
require(equals > 0) { "$context: message arguments must use name=\${property}" }
val name = argument.substring(0, equals).trim()
require(name.matches(argumentNamePattern)) { "$context: invalid message argument name '$name'" }
require(name !in arguments) { "$context: duplicate message argument '$name'" }
arguments[name] = path(argument.substring(equals + 1).trim(), context)
}
return MessageExpression(key, arguments)
}

Expand Down Expand Up @@ -156,6 +163,7 @@ internal object Expressions {
}

private val keyPattern = Regex("[A-Za-z0-9_][A-Za-z0-9_.-]*")
private val argumentNamePattern = Regex("[A-Za-z_][A-Za-z0-9_]*")
private val numberPattern = Regex("-?\\d+")
private val pathVariablePattern = Regex("\\{([A-Za-z_][A-Za-z0-9_]*)}")
}
Loading
Loading