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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Webtools releases

## [Unreleased]
### Added
- `com.diffplug.webtools.jte` plugin ([#10](https://github.com/diffplug/webtools/pull/10))

## [1.2.6] - 2025-08-22
### Fixed
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

- [node](#node) - hassle-free `npm install` and `npm run blah`
- [static server](#static-server) - a simple static file server
- [jte](#jte) - creates idiomatic Kotlin model classes for `jte` templates (strict nullability & idiomatic collections and generics)

## Node

Expand Down Expand Up @@ -30,3 +31,38 @@ tasks.register('serve', com.diffplug.webtools.serve.StaticServerTask) {
port = 8080 // by default
}
```

### JTE

You have to apply `gg.jte.gradle` plugin yourself. We add a task called `jteModels` which creates a Kotlin model classes with strict nullability. Like so:

```jte
// header.jte
@param String title
@param String createdAtAndBy
@param Long idToImpersonateNullable
@param String loginLinkNullable
```

will turn into

```kotlin
class header(
val title: String,
val createdAtAndBy: String,
val idToImpersonateNullable: Long?,
val loginLinkNullable: String?,
) : common.JteModel {

override fun render(engine: TemplateEngine, output: TemplateOutput) {
engine.render("pages/Admin/userShow/header.jte", mapOf(
"title" to title,
"createdAtAndBy" to createdAtAndBy,
"idToImpersonateNullable" to idToImpersonateNullable,
"loginLinkNullable" to loginLinkNullable,
), output)
}
}
```

We also translate Java collections and generics to their Kotlin equivalents. See `JteRenderer.convertJavaToKotlin` for details.
27 changes: 27 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,39 @@ apply from: 干.file('spotless/java.gradle')
apply from: 干.file('base/maven.gradle')
apply from: 干.file('base/sonatype.gradle')

def NEEDS_GLUE = ['jte']
for (glue in NEEDS_GLUE) {
sourceSets.register(glue) {
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
java {}
}
}
jar {
for (glue in NEEDS_GLUE) {
from sourceSets.getByName(glue).output.classesDirs
}
}

spotless {
java {
target 'src/**/*.java'
}
}

dependencies {
// reflection for version decoupling
implementation 'org.jooq:joor:0.9.15'
// node.js
api 'com.github.eirslett:frontend-maven-plugin:1.15.1'
implementation 'com.diffplug.durian:durian-swt.os:5.0.1'
// static file server
String VER_JETTY = '11.0.25'
api "org.eclipse.jetty:jetty-server:$VER_JETTY"
api "org.eclipse.jetty:jetty-servlet:$VER_JETTY"
// jte codegen
String VER_JTE = '3.2.1'
jteCompileOnly gradleApi()
jteCompileOnly "gg.jte:jte-runtime:${VER_JTE}"
jteCompileOnly "gg.jte:jte:${VER_JTE}"
}
7 changes: 6 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ org=diffplug
license=apache
git_url=github.com/diffplug/webtools
plugin_tags=node
plugin_list=node
plugin_list=node jte

ver_java=17

Expand All @@ -15,3 +15,8 @@ plugin_node_id=com.diffplug.webtools.node
plugin_node_impl=com.diffplug.webtools.node.NodePlugin
plugin_node_name=DiffPlug NodeJS
plugin_node_desc=Runs `npm install` and `npm run xx` in an efficient way

plugin_jte_id=com.diffplug.webtools.jte
plugin_jte_impl=com.diffplug.webtools.jte.JtePlugin
plugin_jte_name=DiffPlug JTE
plugin_jte_desc=Runs the JTE plugin and adds typesafe model classes
141 changes: 141 additions & 0 deletions src/jte/java/com/diffplug/webtools/jte/JteRenderer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* Copyright (C) 2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.webtools.jte;

import gg.jte.ContentType;
import gg.jte.TemplateConfig;
import gg.jte.compiler.TemplateParser;
import gg.jte.compiler.TemplateParserVisitorAdapter;
import gg.jte.compiler.TemplateType;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import org.gradle.api.file.FileType;
import org.gradle.work.ChangeType;
import org.gradle.work.InputChanges;

public class JteRenderer {
public static void renderTask(JtePlugin.RenderModelClassesTask task, InputChanges changes) throws IOException {
var templateConfig = new TemplateConfig((ContentType) task.getContentType().get(), task.getPackageName().get());
var renderer = new JteRenderer(task.getInputDir().getAsFile().get(), templateConfig);
for (var change : changes.getFileChanges(task.getInputDir())) {
if (change.getFileType() == FileType.DIRECTORY) {
return;
}
String name = change.getFile().getName();
if (!name.endsWith(".jte") && !name.endsWith(".kte")) {
continue;
}
var targetFileJte = task.getOutputDir().file(change.getNormalizedPath()).get().getAsFile().getAbsolutePath();
var targetFile = new File(targetFileJte.substring(0, targetFileJte.length() - 4) + ".kt");
if (change.getChangeType() == ChangeType.REMOVED) {
targetFile.delete();
} else {
targetFile.getParentFile().mkdirs();
Files.write(targetFile.toPath(), renderer.render(change.getFile()).getBytes(StandardCharsets.UTF_8));
}
}
}

static String convertJavaToKotlin(String javaType) {
if (javaType.equals("boolean")) {
return "Boolean";
} else if (javaType.equals("int")) {
return "Int";
} else {
// e.g. `@param Result<?> records` -> `val records: Result<*>`
return javaType
.replace("<?>", "<*>")
.replace("java.util.Collection", "Collection")
.replace("java.util.List", "List")
.replace("java.util.Map", "Map")
.replace("java.util.Set", "Set");
}
}

final File rootDir;
final TemplateConfig config;

JteRenderer(File rootDir, TemplateConfig config) {
this.rootDir = rootDir;
this.config = config;
}

String render(File file) throws IOException {
var pkg = file.getParentFile().getAbsolutePath().substring(rootDir.getAbsolutePath().length() + 1).replace(File.separatorChar, '.');
var name = file.getName();
var lastDot = name.lastIndexOf('.');
name = name.substring(0, lastDot);
String ext = file.getName().substring(lastDot);

var imports = new LinkedHashSet<String>();
imports.add("gg.jte.TemplateEngine");
imports.add("gg.jte.TemplateOutput");
var params = new LinkedHashMap<String, String>();

new TemplateParser(Files.readString(file.toPath()), TemplateType.Template, new TemplateParserVisitorAdapter() {
@Override
public void onImport(String importClass) {
imports.add(importClass.replace("static ", ""));
}

@Override
public void onParam(String parameter) {
var idxOfColon = parameter.indexOf(':');
if (idxOfColon == -1) { // .jte
// lastIndexOf accounts for valid multiple spaces, e.g `Map<String, String> featureMap`
var spaceIdx = parameter.lastIndexOf(' ');
var type = parameter.substring(0, spaceIdx).trim();
var name = parameter.substring(spaceIdx + 1).trim();
if (name.endsWith("Nullable")) {
type += "?";
}
params.put(name, convertJavaToKotlin(type));
} else { // .kte
var name = parameter.substring(0, idxOfColon).trim();
var type = parameter.substring(idxOfColon + 1).trim();
params.put(name, type);
}
}
}, config).parse();

var builder = new StringBuilder();
builder.append("package " + pkg + "\n");
builder.append("\n");
for (var imp : imports) {
builder.append("import " + imp + "\n");
}
builder.append("\n");
builder.append("class " + name + "(\n");
params.forEach((paramName, type) -> {
builder.append("\tval " + paramName + ": " + type + ",\n");
});
builder.append("\t) : common.JteModel {\n");
builder.append("\n");
builder.append("\toverride fun render(engine: TemplateEngine, output: TemplateOutput) {\n");
builder.append("\t\tengine.render(\"" + pkg.replace('.', '/') + "/" + name + ext + "\", mapOf(\n");
params.forEach((paramName, type) -> {
builder.append("\t\t\t\"" + paramName + "\" to " + paramName + ",\n");
});
builder.append("\t\t), output)\n");
builder.append("\t}\n");
builder.append("}");
return builder.toString();
}
}
79 changes: 79 additions & 0 deletions src/main/java/com/diffplug/webtools/jte/JtePlugin.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Copyright (C) 2025 DiffPlug
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.diffplug.webtools.jte;

import static org.joor.Reflect.on;
import static org.joor.Reflect.onClass;

import java.io.File;
import java.io.IOException;
import org.gradle.api.DefaultTask;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.plugins.JavaPluginExtension;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.*;
import org.gradle.work.Incremental;
import org.gradle.work.InputChanges;

public class JtePlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().apply("gg.jte.gradle");
project.getPlugins().apply("org.jetbrains.kotlin.jvm");
var extension = on(project.getExtensions().getByType(onClass("gg.jte.gradle.JteExtension").type()));

JavaPluginExtension javaPluginExtension = project.getExtensions().getByType(JavaPluginExtension.class);
SourceSet main = javaPluginExtension.getSourceSets().findByName("main");

project.getTasks().named("classes").configure(task -> {
task.getInputs().dir(extension.call("getSourceDirectory"));
});

@SuppressWarnings("unchecked")
var jteModelsTask = project.getTasks().register("jteModels", RenderModelClassesTask.class, task -> {
var jteModels = new File(project.getLayout().getBuildDirectory().getAsFile().get(), "jte-models");
main.getJava().srcDir(jteModels);
task.getOutputDir().set(jteModels);
task.getInputDir().set((File) extension.call("getSourceDirectory").call("get").call("toFile").get());
task.getPackageName().set((Property<String>) extension.call("getPackageName").get());
task.getContentType().set((Property<Enum<?>>) extension.call("getContentType").get());
});
project.getTasks().named("compileKotlin").configure(task -> task.dependsOn(jteModelsTask));
}

public static abstract class RenderModelClassesTask extends DefaultTask {
@Incremental
@PathSensitive(PathSensitivity.RELATIVE)
@InputDirectory
abstract DirectoryProperty getInputDir();

@OutputDirectory
abstract DirectoryProperty getOutputDir();

@Input
abstract Property<String> getPackageName();

@Input
abstract Property<Enum<?>> getContentType();

@TaskAction
public void render(InputChanges changes) throws IOException {
onClass("com.diffplug.webtools.jte.JteRenderer").call("renderTask", this, changes);
}
}
}
Loading