diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bd65a1..dcd138c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 9c53a69..1597b6e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/build.gradle b/build.gradle index 5ef4b5e..a7bb9b3 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,29 @@ 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' @@ -20,4 +42,9 @@ dependencies { 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}" } diff --git a/gradle.properties b/gradle.properties index 753b738..7693bdf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 @@ -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 diff --git a/src/jte/java/com/diffplug/webtools/jte/JteRenderer.java b/src/jte/java/com/diffplug/webtools/jte/JteRenderer.java new file mode 100644 index 0000000..f039d7b --- /dev/null +++ b/src/jte/java/com/diffplug/webtools/jte/JteRenderer.java @@ -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(); + imports.add("gg.jte.TemplateEngine"); + imports.add("gg.jte.TemplateOutput"); + var params = new LinkedHashMap(); + + 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 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(); + } +} diff --git a/src/main/java/com/diffplug/webtools/jte/JtePlugin.java b/src/main/java/com/diffplug/webtools/jte/JtePlugin.java new file mode 100644 index 0000000..f5c33ad --- /dev/null +++ b/src/main/java/com/diffplug/webtools/jte/JtePlugin.java @@ -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 { + @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) extension.call("getPackageName").get()); + task.getContentType().set((Property>) 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 getPackageName(); + + @Input + abstract Property> getContentType(); + + @TaskAction + public void render(InputChanges changes) throws IOException { + onClass("com.diffplug.webtools.jte.JteRenderer").call("renderTask", this, changes); + } + } +}