Factored out most imperative logic from build.gradle.kts to buildSrc.

Removed: RMIC
diff --git a/.gitignore b/.gitignore
index 2ab1d7d..599e1a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,7 @@
 /.ivy/
 /.bin/
 /build/
+/*/build/
 /build.properties
 /archive/
 /ide-dependencies/
diff --git a/build.gradle.kts b/build.gradle.kts
index 3126172..dab5eba 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -18,223 +18,57 @@
  */
 
 import java.nio.charset.StandardCharsets
-import java.nio.file.FileVisitResult
 import java.nio.file.Files
-import java.nio.file.SimpleFileVisitor
-import java.nio.file.attribute.BasicFileAttributes
-import java.time.Instant
-import java.time.ZoneOffset
-import java.time.format.DateTimeFormatter
-import java.util.Properties
-import java.util.TreeSet
 import java.util.stream.Collectors
 
-buildscript {
-    dependencies {
-        classpath("org.apache.freemarker.docgen:freemarker-docgen-core:0.0.2-SNAPSHOT")
-    }
-}
-
 plugins {
-    `java-library`
+    `freemarker-root`
     `maven-publish`
     signing
     id("biz.aQute.bnd.builder") version "6.1.0"
-    id("org.nosphere.apache.rat") version "0.7.0"
 }
 
 group = "org.freemarker"
 
-val buildTimeStamp = Instant.now()
-val buildTimeStampUtc = buildTimeStamp.atOffset(ZoneOffset.UTC)
-val versionFileTokens = mapOf(
-        "timestampNice" to buildTimeStampUtc.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")),
-        "timestampInVersion" to buildTimeStampUtc.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
-)
-val resourceTemplatesDir = file("freemarker-core/src/main/resource-templates")
-val versionPropertiesTemplate = Properties()
-Files.newBufferedReader(resourceTemplatesDir.toPath().resolve("freemarker").resolve("version.properties"), StandardCharsets.ISO_8859_1).use {
-    versionPropertiesTemplate.load(it)
-}
-val versionProperties = HashMap<String, String>()
-versionPropertiesTemplate.forEach { (key, value) ->
-    var updatedValue = value.toString()
-    for (token in versionFileTokens) {
-        updatedValue = updatedValue.replace("@${token.key}@", token.value)
-    }
-    versionProperties[key.toString()] = updatedValue.trim()
-}
-version = versionProperties["mavenVersion"]!!
-val displayVersion = versionProperties["version"]!!
-
-val freemarkerCompilerVersionOverrideRef = providers.gradleProperty("freemarkerCompilerVersionOverride")
-val defaultJavaVersion = freemarkerCompilerVersionOverrideRef
-    .orElse(providers.gradleProperty("freemarkerDefaultJavaVersion"))
-    .getOrElse("8")
-val testJavaVersion = providers.gradleProperty("freeMarkerTestJavaVersion")
-    .getOrElse("16")
-val testRunnerJavaVersion = providers.gradleProperty("freeMarkerTestRunnerJavaVersion")
-    .getOrElse(testJavaVersion)
-val javadocJavaVersion = providers.gradleProperty("freeMarkerJavadocJavaVersion")
-    .getOrElse(defaultJavaVersion)
-val doSignPackages = providers.gradleProperty("signPublication").map { it.toBoolean() }.orElse(true)
-
-java {
-    withSourcesJar()
-    withJavadocJar()
-
-    toolchain {
-        languageVersion.set(JavaLanguageVersion.of(defaultJavaVersion))
-    }
-}
+val fmExt = freemarkerRoot
 
 tasks.withType<JavaCompile>().configureEach {
     options.encoding = "UTF-8"
 }
 
-data class JavaccReplacePattern(val relPath: String, val pattern: String, val replacement: String) : java.io.Serializable
-
-open class CompileJavacc @Inject constructor(
-    private val fs: FileSystemOperations,
-    private val exec: ExecOperations,
-    layout: ProjectLayout,
-    objects: ObjectFactory
-) : DefaultTask() {
-
-    @InputDirectory
-    val sourceDirectory: DirectoryProperty
-
-    @OutputDirectory
-    val destinationDirectory: DirectoryProperty
-
-    @InputFiles
-    var classpath: FileCollection
-
-    @Input
-    val fileNameOverrides: SetProperty<String>
-
-    @Input
-    val replacePatterns: SetProperty<JavaccReplacePattern>
-
-    init {
-        this.sourceDirectory = objects.directoryProperty().value(layout.projectDirectory.dir("src/main/javacc"))
-        this.destinationDirectory = objects.directoryProperty().value(layout.buildDirectory.dir("generated/javacc-tmp"))
-        this.classpath = objects.fileCollection()
-        this.fileNameOverrides = objects.setProperty()
-        this.replacePatterns = objects.setProperty()
-    }
-
-    @TaskAction
-    fun compileFiles() {
-        fs.delete { delete(destinationDirectory) }
-
-        val destRoot = destinationDirectory.get().asFile
-        val javaccClasspath = classpath
-
-        sourceDirectory.asFileTree.visit(object : EmptyFileVisitor() {
-            override fun visitFile(fileDetails: FileVisitDetails) {
-                val outputDir = fileDetails.relativePath.parent.getFile(destRoot)
-                Files.createDirectories(outputDir.toPath())
-
-                val execResult = exec.javaexec {
-                    classpath = javaccClasspath
-                    mainClass.set("org.javacc.parser.Main")
-                    args = listOf(
-                        "-OUTPUT_DIRECTORY=$outputDir",
-                        fileDetails.file.toString()
-                    )
-                    isIgnoreExitValue = true
-                }
-
-                val exitCode = execResult.exitValue
-                if (exitCode != 0) {
-                    throw IllegalStateException("Javacc failed with error code: $exitCode")
-                }
-            }
-        })
-
-        val fileNameOverridesSnapshot = fileNameOverrides.get()
-        val deletedFileNames = HashSet<String>()
-
-        Files.walkFileTree(destRoot.toPath(), object : SimpleFileVisitor<java.nio.file.Path>() {
-            override fun visitFile(file: java.nio.file.Path, attrs: BasicFileAttributes): FileVisitResult {
-                val fileName = file.fileName.toString()
-                if (fileNameOverridesSnapshot.contains(fileName)) {
-                    deletedFileNames.add(fileName)
-                    Files.delete(file)
-                }
-                return FileVisitResult.CONTINUE
-            }
-        })
-
-        val unusedFileNames = TreeSet(fileNameOverridesSnapshot)
-        unusedFileNames.removeAll(deletedFileNames)
-        if (unusedFileNames.isNotEmpty()) {
-            logger.warn("Javacc did not generate the following files, even though they are marked as overriden: ${unusedFileNames}")
-        }
-
-        replacePatterns.get().groupBy { it.relPath }.forEach { (relPath, patternDefs) ->
-            val file = destRoot.toPath().resolve(relPath.replace("/", File.separator))
-
-            val encoding = StandardCharsets.ISO_8859_1
-            val origContent = String(Files.readAllBytes(file), encoding)
-            var adjContent = origContent
-            for (patternDef in patternDefs) {
-                val prevContent = adjContent
-                adjContent = adjContent.replace(patternDef.pattern, patternDef.replacement)
-                if (prevContent == adjContent) {
-                    logger.warn("$file was not modified, because it does not contain the requested token: '${patternDef.pattern}'")
-                }
-            }
-
-            if (origContent != adjContent) {
-                Files.write(file, adjContent.toByteArray(encoding))
-            }
-        }
-    }
-}
-
-val compileJavacc = tasks.register<CompileJavacc>("compileJavacc") {
+val compileJavacc = tasks.register<freemarker.build.CompileJavaccTask>("compileJavacc") {
     sourceDirectory.set(file("freemarker-core/src/main/javacc"))
     destinationDirectory.set(buildDir.toPath().resolve("generated").resolve("javacc").toFile())
-    classpath = configurations.detachedConfiguration(dependencies.create("net.java.dev.javacc:javacc:7.0.12"))
+    javaccVersion.set("7.0.12")
+
     fileNameOverrides.addAll(
         "ParseException.java",
         "TokenMgrError.java"
     )
 
     val basePath = "freemarker/core"
-    replacePatterns.addAll(
-        JavaccReplacePattern(
-            "${basePath}/FMParser.java",
-            "enum",
-            "ENUM"
-        ),
-        JavaccReplacePattern(
-            "${basePath}/FMParserConstants.java",
-            "public interface FMParserConstants",
-            "interface FMParserConstants"
-        ),
-        JavaccReplacePattern(
-            "${basePath}/Token.java",
-            "public class Token",
-            "class Token"
-        ),
-        // FIXME: This does nothing at the moment.
-        JavaccReplacePattern(
-            "${basePath}/SimpleCharStream.java",
-            "public final class SimpleCharStream",
-            "final class SimpleCharStream"
-        )
-    )
-}
 
-fun <T> concatLists(vararg lists: List<T>): List<T> {
-    val concatenated = ArrayList<T>()
-    for (list in lists) {
-        concatenated.addAll(list)
-    }
-    return concatenated
+    replacePattern(
+        "${basePath}/FMParser.java",
+        "enum",
+        "ENUM"
+    )
+    replacePattern(
+        "${basePath}/FMParserConstants.java",
+        "public interface FMParserConstants",
+        "interface FMParserConstants"
+    )
+    replacePattern(
+        "${basePath}/Token.java",
+        "public class Token",
+        "class Token"
+    )
+    // FIXME: This does nothing at the moment.
+    replacePattern(
+        "${basePath}/SimpleCharStream.java",
+        "public final class SimpleCharStream",
+        "final class SimpleCharStream"
+    )
 }
 
 val allSourceSetNames = ArrayList<String>()
@@ -242,7 +76,7 @@
 fun configureSourceSet(sourceSetName: String, defaultCompilerVersionStr: String) {
     allSourceSetNames.add(sourceSetName)
 
-    val compilerVersion = freemarkerCompilerVersionOverrideRef
+    val compilerVersion = fmExt.freemarkerCompilerVersionOverrideRef
         .orElse(providers.gradleProperty("java${defaultCompilerVersionStr}CompilerOverride"))
         .getOrElse(defaultCompilerVersionStr)
         .let { JavaLanguageVersion.of(it) }
@@ -309,7 +143,7 @@
 
 tasks.named<JavaCompile>(sourceSets["test"].compileJavaTaskName) {
     javaCompiler.set(javaToolchains.compilerFor {
-        languageVersion.set(JavaLanguageVersion.of(testJavaVersion))
+        languageVersion.set(JavaLanguageVersion.of(fmExt.testJavaVersion))
     })
 }
 
@@ -356,7 +190,7 @@
     systemProperty("freemarker.test.resourcesDir", resourcesDestDir)
 
     javaLauncher.set(javaToolchains.launcherFor {
-        languageVersion.set(JavaLanguageVersion.of(testRunnerJavaVersion))
+        languageVersion.set(JavaLanguageVersion.of(fmExt.testJavaVersion))
     })
 }
 
@@ -381,67 +215,12 @@
 
 tasks.named<Jar>(sourceSets.named(SourceSet.MAIN_SOURCE_SET_NAME).get().sourcesJarTaskName) {
     from(compileJavacc.flatMap { it.sourceDirectory })
-    from(resourceTemplatesDir)
 
     from(files("LICENSE", "NOTICE")) {
         into("META-INF")
     }
 }
 
-
-tasks.named<ProcessResources>("processResources") {
-    with(copySpec {
-        from(resourceTemplatesDir)
-        filter<org.apache.tools.ant.filters.ReplaceTokens>(mapOf("tokens" to versionFileTokens))
-    })
-}
-
-val rmicOutputDir: java.nio.file.Path = buildDir.toPath().resolve("rmic")
-
-tasks.register("rmic") {
-    val rmicClasspath = objects.fileCollection()
-
-    val sourceSet = sourceSets[SourceSet.MAIN_SOURCE_SET_NAME]
-    val compileTaskName = sourceSet.compileJavaTaskName
-    val compileTaskRef = tasks.named<JavaCompile>(compileTaskName)
-
-    rmicClasspath.from(compileTaskRef.map { it.classpath })
-    rmicClasspath.from(compileTaskRef.map { it.outputs })
-
-    inputs.files(rmicClasspath)
-    outputs.dir(rmicOutputDir)
-
-    doLast {
-        val allClassesDirs = sourceSet.output.classesDirs
-
-        val rmicRelSrcPath = listOf("freemarker", "debug", "impl")
-        val rmicSrcPattern = "Rmi*Impl.class"
-
-        val rmicRelSrcPathStr = rmicRelSrcPath.joinToString(separator = File.separator)
-        val classesDir = allClassesDirs.find { candidateDir ->
-            Files.newDirectoryStream(candidateDir.toPath().resolve(rmicRelSrcPathStr), rmicSrcPattern).use { files ->
-                val firstFile = files.first { Files.isRegularFile(it) }
-                firstFile != null
-            }
-        }
-
-        if (classesDir != null) {
-            ant.withGroovyBuilder {
-                "rmic"(
-                    "classpath" to rmicClasspath.asPath,
-                    "base" to classesDir.toString(),
-                    "destDir" to rmicOutputDir.toString(),
-                    "includes" to "${rmicRelSrcPath.joinToString("/")}/$rmicSrcPattern",
-                    "verify" to "yes",
-                    "stubversion" to "1.2"
-                )
-            }
-        } else {
-            throw IllegalStateException("Couldn't find classes dir in ${allClassesDirs.asPath}")
-        }
-    }
-}
-
 // This source set is only needed, because the OSGI plugin supports only a single sourceSet.
 // We are deleting it, because otherwise it would fool IDEs that a source root has multiple owners.
 val osgiSourceSet = sourceSets.create("osgi") {
@@ -460,13 +239,11 @@
 sourceSets.remove(osgiSourceSet)
 
 tasks.named<Jar>(JavaPlugin.JAR_TASK_NAME) {
-    from(rmicOutputDir)
-
     configure<aQute.bnd.gradle.BundleTaskExtension> {
         bndfile.set(file("osgi.bnd"))
 
         setSourceSet(osgiSourceSet)
-        properties.putAll(versionProperties)
+        properties.putAll(fmExt.versionDef.versionProperties)
         properties.put("moduleOrg", project.group.toString())
         properties.put("moduleName", project.name)
     }
@@ -474,12 +251,14 @@
 
 tasks.named<Javadoc>(JavaPlugin.JAVADOC_TASK_NAME) {
     javadocTool.set(javaToolchains.javadocToolFor {
-        languageVersion.set(JavaLanguageVersion.of(javadocJavaVersion))
+        languageVersion.set(JavaLanguageVersion.of(fmExt.javadocJavaVersion))
     })
 
     val javadocEncoding = StandardCharsets.UTF_8
 
     options {
+        val displayVersion = fmExt.versionDef.displayVersion
+
         locale = "en_US"
         encoding = javadocEncoding.name()
         windowTitle = "FreeMarker ${displayVersion} API"
@@ -499,77 +278,26 @@
 
     classpath = files(configurations.named("combinedClasspath"))
 
-    doLast {
-        val stylesheetPath = destinationDir!!.toPath().resolve("stylesheet.css")
-        logger.info("Fixing JDK 8 ${stylesheetPath}")
-
-        val ddSelectorStart = "(?:\\.contentContainer\\s+\\.(?:details|description)|\\.serializedFormContainer)\\s+dl\\s+dd\\b.*?\\{[^\\}]*\\b"
-        val ddPropertyEnd = "\\b.+?;"
-
-        val fixRules = listOf(
-            Pair(Regex("/\\* (Javadoc style sheet) \\*/"), "/\\* \\1 - JDK 8 usability fix regexp substitutions applied \\*/"),
-            Pair(Regex("@import url\\('resources/fonts/dejavu.css'\\);\\s*"), ""),
-            Pair(Regex("['\"]DejaVu Sans['\"]"), "Arial"),
-            Pair(Regex("['\"]DejaVu Sans Mono['\"]"), "'Courier New'"),
-            Pair(Regex("['\"]DejaVu Serif['\"]"), "Arial"),
-            Pair(Regex("(?<=[\\s,:])serif\\b"), "sans-serif"),
-            Pair(Regex("(?<=[\\s,:])Georgia,\\s*"), ""),
-            Pair(Regex("['\"]Times New Roman['\"],\\s*"), ""),
-            Pair(Regex("(?<=[\\s,:])Times,\\s*"), ""),
-            Pair(Regex("(?<=[\\s,:])Arial\\s*,\\s*Arial\\b"), ""),
-            Pair(Regex("(${ddSelectorStart})margin${ddPropertyEnd}"), "\\1margin: 5px 0 10px 20px;"),
-            Pair(Regex("(${ddSelectorStart})font-family${ddPropertyEnd}"), "\\1")
-        )
-
-        var stylesheetContent = String(Files.readAllBytes(stylesheetPath), javadocEncoding)
-        for (rule in fixRules) {
-            val prevContent = stylesheetContent
-            stylesheetContent = stylesheetContent.replace(rule.first, rule.second)
-
-            if (prevContent == stylesheetContent) {
-                logger.warn("Javadoc style sheet did not contain anything matching: ${rule.first}")
-            }
-        }
-
-        Files.write(stylesheetPath, stylesheetContent.toByteArray(javadocEncoding))
-    }
+    doLast(freemarker.build.JavadocStyleAdjustments())
 }
 
-fun registerManualTask(taskName: String, locale: String, offline: Boolean) {
-    val manualTaskRef = tasks.register(taskName) {
-        val inputDir = file("freemarker-manual/src/main/docgen/${locale}")
-        inputs.dir(inputDir)
+fun registerManualTask(taskName: String, localeValue: String, offlineValue: Boolean) {
+    val manualTaskRef = tasks.register<freemarker.build.ManualBuildTask>(taskName) {
+        inputDirectory.set(file("freemarker-manual/src/main/docgen/${localeValue}"))
 
-        val outputDir = buildDir.toPath().resolve("manual-${if (offline) "offline" else "online"}").resolve(locale)
-        outputs.dir(outputDir)
-
-        description = if (offline) "Build the Manual for offline use" else "Build the Manual to be upload to the FreeMarker homepage"
-
-        doLast {
-            val transform = org.freemarker.docgen.core.Transform()
-            transform.offline = offline
-            transform.sourceDirectory = inputDir
-            transform.destinationDirectory = outputDir.toFile()
-
-            transform.execute()
-        }
+        offline.set(offlineValue)
+        locale.set(localeValue)
     }
-
     tasks.named(LifecycleBasePlugin.BUILD_TASK_NAME) { dependsOn(manualTaskRef) }
 }
 
 registerManualTask("manualOffline", "en_US", true)
 registerManualTask("manualOnline", "en_US", false)
 
-afterEvaluate {
-    // We are setting this, so the pom.xml will be generated properly
-    System.setProperty("line.separator", "\n")
-}
-
 publishing {
     repositories {
         maven {
-            val snapshot = project.version.toString().endsWith("-SNAPSHOT")
+            val snapshot = fmExt.versionDef.version.endsWith("-SNAPSHOT")
             val defaultDeployUrl = if (snapshot) "https://repository.apache.org/content/repositories/snapshots" else "https://repository.apache.org/service/local/staging/deploy/maven2"
             setUrl(providers.gradleProperty("freemarkerDeployUrl").getOrElse(defaultDeployUrl))
             name = providers.gradleProperty("freemarkerDeployServerId").getOrElse("apache.releases.https")
@@ -632,7 +360,7 @@
                     connection.set("scm:git:https://git-wip-us.apache.org/repos/asf/freemarker.git")
                     developerConnection.set("scm:git:https://git-wip-us.apache.org/repos/asf/freemarker.git")
                     url.set("https://git-wip-us.apache.org/repos/asf?p=freemarker.git")
-                    tag.set("v${project.version}")
+                    tag.set("v${fmExt.versionDef.version}")
                 }
 
                 issueManagement {
@@ -662,7 +390,7 @@
                 }
             }
         }
-        if (doSignPackages.get()) {
+        if (fmExt.doSignPackages.get()) {
             signing.sign(mainPublication)
         }
     }
@@ -672,21 +400,14 @@
 val distDir = buildDir.toPath().resolve("distributions")
 
 fun registerSignatureTask(archiveTask: TaskProvider<Tar>) {
-    val signTask = tasks.register(archiveTask.name + "Signature") {
-        val archiveFileRef = archiveTask.flatMap { task -> task.archiveFile }
-
-        inputs.file(archiveFileRef)
-        outputs.file(archiveFileRef.map { f -> File(f.asFile.toString() + ".asc") })
-
-        doLast {
-            signing.sign(archiveTask.get().archiveFile.get().asFile)
-        }
+    val signTask = tasks.register<freemarker.build.SignatureTask>(archiveTask.name + "Signature") {
+        inputFile.set(archiveTask.flatMap { task -> task.archiveFile })
     }
 
     tasks.named(LifecycleBasePlugin.BUILD_TASK_NAME) {
         dependsOn(archiveTask)
 
-        if (doSignPackages.get()) {
+        if (fmExt.doSignPackages.get()) {
             dependsOn(signTask)
         }
     }
@@ -694,8 +415,7 @@
 
 fun registerCommonFiles(tar: Tar) {
     tar.from("README.md") {
-        val displayVersion = versionProperties.get("version")!!
-        filter { content -> content.replace("{version}", displayVersion) }
+        filter { content -> content.replace("{version}", fmExt.versionDef.displayVersion) }
     }
 
     tar.from(files("NOTICE", "RELEASE-NOTES"))
@@ -770,12 +490,6 @@
     }
 }
 
-tasks.withType<org.nosphere.apache.rat.RatTask>() {
-    doLast {
-        println("RAT (${name} task) report was successful: ${reportDir.get().asFile.toPath().resolve("index.html").toUri()}")
-    }
-}
-
 tasks.named<org.nosphere.apache.rat.RatTask>("rat") {
     inputDir.set(projectDir)
     excludes.addAll(readExcludeFile(file("rat-excludes")))
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
new file mode 100644
index 0000000..5058c92
--- /dev/null
+++ b/buildSrc/build.gradle.kts
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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.
+ */
+
+plugins {
+    `kotlin-dsl`
+}
+
+dependencies {
+    implementation(gradleApi())
+
+    implementation("org.apache.freemarker.docgen:freemarker-docgen-core:0.0.2-SNAPSHOT")
+    implementation("org.nosphere.apache:creadur-rat-gradle:0.7.0")
+}
diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts
new file mode 100644
index 0000000..49565d2
--- /dev/null
+++ b/buildSrc/settings.gradle.kts
@@ -0,0 +1,22 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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.
+ */
+
+rootProject.name = "freemarker-buildSrc"
+
+apply(from = rootDir.toPath().parent.resolve("gradle").resolve("repositories.gradle.kts"))
diff --git a/buildSrc/src/main/kotlin/freemarker/build/CompileJavaccTask.kt b/buildSrc/src/main/kotlin/freemarker/build/CompileJavaccTask.kt
new file mode 100644
index 0000000..dbf9444
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/CompileJavaccTask.kt
@@ -0,0 +1,234 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import java.io.File
+import java.nio.charset.StandardCharsets
+import java.nio.file.FileVisitResult
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.SimpleFileVisitor
+import java.nio.file.attribute.BasicFileAttributes
+import java.util.TreeSet
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.EmptyFileVisitor
+import org.gradle.api.file.FileSystemOperations
+import org.gradle.api.file.FileVisitDetails
+import org.gradle.api.file.ProjectLayout
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.ListProperty
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.property
+import org.gradle.kotlin.dsl.setProperty
+import org.gradle.kotlin.dsl.submit
+import org.gradle.process.ExecOperations
+import org.gradle.workers.WorkAction
+import org.gradle.workers.WorkParameters
+import org.gradle.workers.WorkerExecutor
+
+private const val JAVACC_MAIN_CLASS = "org.javacc.parser.Main"
+
+data class JavaccReplacePattern(val relPath: String, val pattern: String, val replacement: String) : java.io.Serializable
+
+open class CompileJavaccTask @Inject constructor(
+    private val fs: FileSystemOperations,
+    private val execOps: ExecOperations,
+    private val exec: WorkerExecutor,
+    layout: ProjectLayout,
+    objects: ObjectFactory
+) : DefaultTask() {
+
+    @InputDirectory
+    @SkipWhenEmpty
+    @IgnoreEmptyDirectories
+    @PathSensitive(PathSensitivity.RELATIVE)
+    val sourceDirectory = objects.directoryProperty().value(layout.projectDirectory.dir("src/main/javacc"))
+
+    @Input
+    val javaccVersion = objects.property<String>().value("7.0.12")
+
+    private val javaccClasspath = objects.fileCollection().apply {
+        val dependencies = project.dependencies
+        val configurations = project.configurations
+
+        from(javaccVersion.map { versionValue ->
+            dependencies
+                .create("net.java.dev.javacc:javacc:${versionValue}")
+                .let { configurations.detachedConfiguration(it) }
+        })
+    }
+
+    @Input
+    val fileNameOverrides = objects.setProperty<String>()
+
+    @Input
+    val replacePatterns = objects.setProperty<JavaccReplacePattern>()
+
+    @OutputDirectory
+    val destinationDirectory = objects.directoryProperty().value(layout.buildDirectory.dir("generated/javacc"))
+
+    fun replacePattern(relPath: String, pattern: String, replacement: String) {
+        replacePatterns.add(JavaccReplacePattern(relPath, pattern, replacement))
+    }
+
+    private fun hasJavaccOnClasspath(): Boolean {
+        try {
+            Class.forName(JAVACC_MAIN_CLASS)
+            return true
+        } catch (ex: ClassNotFoundException) {
+            return false
+        }
+    }
+
+    @TaskAction
+    fun compileFiles() {
+        fs.delete { delete(destinationDirectory) }
+
+        val destRoot = destinationDirectory.get().asFile
+
+        compileFiles(destRoot)
+        deleteOverriddenFiles(destRoot)
+        updateFiles(destRoot)
+    }
+
+    private fun compileFiles(destRoot: File) {
+        withJavaccRunner {
+            sourceDirectory.asFileTree.visit(object : EmptyFileVisitor() {
+                override fun visitFile(fileDetails: FileVisitDetails) {
+                    val outputDir = fileDetails.relativePath.parent.getFile(destRoot)
+                    Files.createDirectories(outputDir.toPath())
+
+                    runJavacc(listOf(
+                        "-OUTPUT_DIRECTORY=$outputDir",
+                        fileDetails.file.toString()
+                    ))
+                }
+            })
+        }
+    }
+
+    private fun deleteOverriddenFiles(destRoot: File) {
+        val fileNameOverridesSnapshot = fileNameOverrides.get()
+        val deletedFileNames = HashSet<String>()
+
+        Files.walkFileTree(destRoot.toPath(), object : SimpleFileVisitor<Path>() {
+            override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult {
+                val fileName = file.fileName.toString()
+                if (fileNameOverridesSnapshot.contains(fileName)) {
+                    deletedFileNames.add(fileName)
+                    Files.delete(file)
+                }
+                return FileVisitResult.CONTINUE
+            }
+        })
+
+        val unusedFileNames = TreeSet(fileNameOverridesSnapshot)
+        unusedFileNames.removeAll(deletedFileNames)
+        if (unusedFileNames.isNotEmpty()) {
+            logger.warn("Javacc did not generate the following files," +
+                    " even though they are marked as overridden: $unusedFileNames")
+        }
+    }
+
+    private fun updateFiles(destRoot: File) {
+        replacePatterns.get().groupBy { it.relPath }.forEach { (relPath, patternDefs) ->
+            val file = destRoot.toPath().resolve(relPath.replace("/", File.separator))
+
+            val encoding = StandardCharsets.ISO_8859_1
+            val origContent = String(Files.readAllBytes(file), encoding)
+            var adjContent = origContent
+            for (patternDef in patternDefs) {
+                val prevContent = adjContent
+                adjContent = adjContent.replace(patternDef.pattern, patternDef.replacement)
+                if (prevContent == adjContent) {
+                    logger.warn("$file was not modified, because it does not contain the requested token: '${patternDef.pattern}'")
+                }
+            }
+
+            if (origContent != adjContent) {
+                Files.write(file, adjContent.toByteArray(encoding))
+            }
+        }
+    }
+
+    private fun withJavaccRunner(action: JavaccRunner.() -> Unit) {
+        if (hasJavaccOnClasspath()) {
+            logger.warn("Found Javacc (${JAVACC_MAIN_CLASS}) on classpath. Switching to process isolation," +
+                    " because Javacc relies on static fields. Consider removing Javacc from the classpath" +
+                    " to improve performance."
+            )
+            withProcessJavaccRunner(action)
+        } else {
+            withClasspathJavaccRunner(action)
+        }
+    }
+
+    private fun withProcessJavaccRunner(action: JavaccRunner.() -> Unit) {
+        action.invoke { actionArgs ->
+            val execResult = execOps.javaexec {
+                classpath = javaccClasspath
+                mainClass.set(JAVACC_MAIN_CLASS)
+                args = actionArgs
+                isIgnoreExitValue = true
+            }
+
+            checkJavaccError(execResult.exitValue)
+        }
+    }
+
+    private fun withClasspathJavaccRunner(action: JavaccRunner.() -> Unit) {
+        val workQueue = exec.classLoaderIsolation { classpath.from(javaccClasspath) }
+        action.invoke { actionArgs ->
+            workQueue.submit(JavaccRunnerWorkAction::class) { arguments.set(actionArgs) }
+        }
+        workQueue.await()
+    }
+
+    private fun interface JavaccRunner {
+        fun runJavacc(args: List<String>)
+    }
+
+    interface JavaccCommandLineParameters : WorkParameters {
+        val arguments: ListProperty<String>
+    }
+
+    abstract class JavaccRunnerWorkAction @Inject constructor() : WorkAction<JavaccCommandLineParameters> {
+        override fun execute() {
+            Class.forName(JAVACC_MAIN_CLASS)
+                .getMethod("mainProgram", Array<String>::class.java)
+                .invoke(null, parameters.arguments.get().toTypedArray())
+                .let { checkJavaccError(it as Int) }
+        }
+    }
+}
+
+private fun checkJavaccError(errorCode: Int) {
+    if (errorCode != 0) {
+        throw IllegalStateException("Javacc failed with error code: $errorCode")
+    }
+}
diff --git a/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt
new file mode 100644
index 0000000..d687754
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootExtension.kt
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import org.gradle.api.provider.ProviderFactory
+
+class FreemarkerRootExtension constructor(val versionDef: FreemarkerVersionDef, providers: ProviderFactory) {
+    val freemarkerCompilerVersionOverrideRef = providers.gradleProperty("freemarkerCompilerVersionOverride")
+
+    val defaultJavaVersion = freemarkerCompilerVersionOverrideRef
+        .orElse(providers.gradleProperty("freemarkerDefaultJavaVersion"))
+        .getOrElse("8")
+
+    val testJavaVersion = providers.gradleProperty("freeMarkerTestJavaVersion")
+        .getOrElse("16")
+
+    val javadocJavaVersion = providers.gradleProperty("freeMarkerJavadocJavaVersion")
+        .getOrElse(defaultJavaVersion)
+
+    val doSignPackages = providers.gradleProperty("signPublication").map { it.toBoolean() }.orElse(true)
+}
diff --git a/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootPlugin.kt b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootPlugin.kt
new file mode 100644
index 0000000..4b51ec8
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/FreemarkerRootPlugin.kt
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import java.util.Properties
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.set
+import org.apache.tools.ant.filters.ReplaceTokens
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.plugins.JavaPlugin
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.bundling.Jar
+import org.gradle.jvm.toolchain.JavaLanguageVersion
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.filter
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.kotlin.dsl.named
+import org.gradle.kotlin.dsl.withType
+import org.gradle.language.jvm.tasks.ProcessResources
+
+private val RESOURCE_TEMPLATES_PATH = listOf("freemarker-core", "src", "main", "resource-templates")
+private val VERSION_PROPERTIES_PATH = listOf("freemarker", "version.properties")
+
+class FreemarkerVersionDef(versionFileTokens: Map<String, String>, versionProperties: Map<String, String>) {
+    val versionFileTokens: Map<String, String>
+    val versionProperties: Map<String, String>
+    val version: String
+    val displayVersion: String
+
+    init {
+        this.versionFileTokens = versionFileTokens.toMap()
+        this.versionProperties = versionProperties.toMap()
+        this.version = this.versionProperties["mavenVersion"]!!
+        this.displayVersion = this.versionProperties["version"]!!
+    }
+}
+
+private fun withChildPaths(root: Path, children: List<String>): Path {
+    return children
+            .fold(root) { parent, child -> parent.resolve(child) }
+}
+
+open class FreemarkerRootPlugin : Plugin<Project> {
+    private class Configurer(
+        private val project: Project,
+        private val ext: FreemarkerRootExtension
+        ) {
+
+        private val tasks = project.tasks
+        private val java = project.extensions.getByType<JavaPluginExtension>()
+        private val mainSourceSet = java.sourceSets.named(SourceSet.MAIN_SOURCE_SET_NAME).get()
+
+        fun configure() {
+            project.version = ext.versionDef.version
+            project.extensions.add("freemarkerRoot", ext)
+
+            project.configure<JavaPluginExtension> {
+                withSourcesJar()
+                withJavadocJar()
+
+                toolchain {
+                    languageVersion.set(JavaLanguageVersion.of(ext.defaultJavaVersion))
+                }
+            }
+
+            val resourceTemplatesDir = withChildPaths(project.projectDir.toPath(), RESOURCE_TEMPLATES_PATH)
+
+            tasks.named<ProcessResources>(JavaPlugin.PROCESS_RESOURCES_TASK_NAME) {
+                with(project.copySpec {
+                    from(resourceTemplatesDir)
+                    filter<ReplaceTokens>(mapOf("tokens" to ext.versionDef.versionFileTokens))
+                })
+            }
+
+            tasks.named<Jar>(mainSourceSet.sourcesJarTaskName) {
+                from(resourceTemplatesDir)
+            }
+
+            tasks.withType<org.nosphere.apache.rat.RatTask>() {
+                doLast {
+                    println("RAT (${name} task) report was successful: ${reportDir.get().asFile.toPath().resolve("index.html").toUri()}")
+                }
+            }
+        }
+    }
+
+    override fun apply(project: Project) {
+        project.pluginManager.apply("java-library")
+        project.pluginManager.apply("org.nosphere.apache.rat")
+
+        val ext = FreemarkerRootExtension(readVersions(project), project.providers)
+
+        Configurer(project, ext).configure()
+
+        project.afterEvaluate {
+            // We are setting this, so the pom.xml will be generated properly
+            System.setProperty("line.separator", "\n")
+        }
+    }
+
+    private fun readVersions(project: Project): FreemarkerVersionDef {
+        val developmentBuild = project.providers
+                .gradleProperty("developmentBuild")
+                .map { it.toBoolean() }
+                .getOrElse(false)
+
+        if (developmentBuild) {
+            println("DEVELOPMENT BUILD: Using EPOCH as timestamp.")
+        }
+
+        val buildTimeStamp = if (developmentBuild) Instant.EPOCH else Instant.now()
+
+        val buildTimeStampUtc = buildTimeStamp.atOffset(ZoneOffset.UTC)
+        val versionFileTokens = mapOf(
+            "timestampNice" to buildTimeStampUtc.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")),
+            "timestampInVersion" to buildTimeStampUtc.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'"))
+        )
+
+        val versionPropertiesPath = withChildPaths(project.projectDir.toPath(), RESOURCE_TEMPLATES_PATH + VERSION_PROPERTIES_PATH)
+
+        val versionPropertiesTemplate = Properties()
+        Files.newBufferedReader(versionPropertiesPath, StandardCharsets.ISO_8859_1).use {
+            versionPropertiesTemplate.load(it)
+        }
+
+        val versionProperties = HashMap<String, String>()
+        versionPropertiesTemplate.forEach { (key, value) ->
+            var updatedValue = value.toString()
+            for (token in versionFileTokens) {
+                updatedValue = updatedValue.replace("@${token.key}@", token.value)
+            }
+            versionProperties[key.toString()] = updatedValue.trim()
+        }
+        return FreemarkerVersionDef(versionFileTokens, versionProperties)
+    }
+}
diff --git a/buildSrc/src/main/kotlin/freemarker/build/JavadocStyleAdjustments.kt b/buildSrc/src/main/kotlin/freemarker/build/JavadocStyleAdjustments.kt
new file mode 100644
index 0000000..7e4c8f9
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/JavadocStyleAdjustments.kt
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import java.nio.charset.Charset
+import java.nio.file.Files
+import org.gradle.api.Action
+import org.gradle.api.Task
+import org.gradle.api.tasks.javadoc.Javadoc
+
+class JavadocStyleAdjustments: Action<Task> {
+    override fun execute(task: Task) {
+        val javadoc = task as Javadoc
+        val stylesheetPath = javadoc.destinationDir!!.toPath().resolve("stylesheet.css")
+        task.logger.info("Fixing JDK 8 ${stylesheetPath}")
+
+        val ddSelectorStart = "(?:\\.contentContainer\\s+\\.(?:details|description)|\\.serializedFormContainer)\\s+dl\\s+dd\\b.*?\\{[^\\}]*\\b"
+        val ddPropertyEnd = "\\b.+?;"
+
+        val fixRules = listOf(
+            Pair(Regex("/\\* (Javadoc style sheet) \\*/"), "/\\* \\1 - JDK 8 usability fix regexp substitutions applied \\*/"),
+            Pair(Regex("@import url\\('resources/fonts/dejavu.css'\\);\\s*"), ""),
+            Pair(Regex("['\"]DejaVu Sans['\"]"), "Arial"),
+            Pair(Regex("['\"]DejaVu Sans Mono['\"]"), "'Courier New'"),
+            Pair(Regex("['\"]DejaVu Serif['\"]"), "Arial"),
+            Pair(Regex("(?<=[\\s,:])serif\\b"), "sans-serif"),
+            Pair(Regex("(?<=[\\s,:])Georgia,\\s*"), ""),
+            Pair(Regex("['\"]Times New Roman['\"],\\s*"), ""),
+            Pair(Regex("(?<=[\\s,:])Times,\\s*"), ""),
+            Pair(Regex("(?<=[\\s,:])Arial\\s*,\\s*Arial\\b"), ""),
+            Pair(Regex("(${ddSelectorStart})margin${ddPropertyEnd}"), "\\1margin: 5px 0 10px 20px;"),
+            Pair(Regex("(${ddSelectorStart})font-family${ddPropertyEnd}"), "\\1")
+        )
+
+        val javadocEncoding = Charset.forName(javadoc.options.encoding)
+
+        var stylesheetContent = String(Files.readAllBytes(stylesheetPath), javadocEncoding)
+        for (rule in fixRules) {
+            val prevContent = stylesheetContent
+            stylesheetContent = stylesheetContent.replace(rule.first, rule.second)
+
+            if (prevContent == stylesheetContent) {
+                task.logger.warn("Javadoc style sheet did not contain anything matching: ${rule.first}")
+            }
+        }
+
+        Files.write(stylesheetPath, stylesheetContent.toByteArray(javadocEncoding))
+    }
+}
diff --git a/buildSrc/src/main/kotlin/freemarker/build/ManualBuildTask.kt b/buildSrc/src/main/kotlin/freemarker/build/ManualBuildTask.kt
new file mode 100644
index 0000000..db084b1
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/ManualBuildTask.kt
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import javax.inject.Inject
+import org.freemarker.docgen.core.Transform
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.ProjectLayout
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.SkipWhenEmpty
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.property
+
+open class ManualBuildTask @Inject constructor(
+    layout: ProjectLayout,
+    objects: ObjectFactory
+) : DefaultTask() {
+
+    @InputDirectory
+    @SkipWhenEmpty
+    @PathSensitive(PathSensitivity.NONE)
+    val inputDirectory: DirectoryProperty
+
+    @Input
+    val offline: Property<Boolean>
+
+    @Input
+    val locale: Property<String>
+
+    @OutputDirectory
+    val destinationDirectory: DirectoryProperty
+
+    init {
+        this.offline = objects.property<Boolean>().value(true)
+        this.locale = objects.property<String>().value("unknown")
+
+        this.inputDirectory = objects.directoryProperty()
+        this.destinationDirectory = this.offline
+            .zip(layout.buildDirectory) {
+                    offlineValue, buildDirValue -> buildDirValue.asFile.toPath().resolve("manual-${if (offlineValue) "offline" else "online"}")
+            }
+            .zip(this.locale) { localelessDirValue, localeValue -> localelessDirValue.resolve(localeValue).toFile() }
+            .let { objects.directoryProperty().value(layout.dir(it)) }
+    }
+
+    @TaskAction
+    fun buildManual() {
+        val transform = Transform()
+        transform.offline = offline.get()
+        transform.sourceDirectory = inputDirectory.get().asFile
+        transform.destinationDirectory = destinationDirectory.get().asFile
+
+        transform.execute()
+    }
+}
diff --git a/buildSrc/src/main/kotlin/freemarker/build/SignatureTask.kt b/buildSrc/src/main/kotlin/freemarker/build/SignatureTask.kt
new file mode 100644
index 0000000..ec66de0
--- /dev/null
+++ b/buildSrc/src/main/kotlin/freemarker/build/SignatureTask.kt
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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 freemarker.build
+
+import java.io.File
+import javax.inject.Inject
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.model.ObjectFactory
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.getByType
+import org.gradle.plugins.signing.SigningExtension
+
+open class SignatureTask @Inject constructor(
+    objects: ObjectFactory
+) : DefaultTask() {
+
+    @InputFile
+    @PathSensitive(PathSensitivity.NONE)
+    val inputFile: RegularFileProperty
+
+    @OutputFile
+    val outputFile: Provider<File>
+
+    private val signing : SigningExtension
+
+    init {
+        this.inputFile = objects.fileProperty()
+        this.outputFile = this.inputFile.map { f -> File("${f.asFile}.asc") }
+        this.signing = project.extensions.getByType()
+    }
+
+    @TaskAction
+    fun signFile() {
+        signing.sign(inputFile.get().asFile)
+    }
+}
diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/freemarker-root.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/freemarker-root.properties
new file mode 100644
index 0000000..9ae0053
--- /dev/null
+++ b/buildSrc/src/main/resources/META-INF/gradle-plugins/freemarker-root.properties
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+
+implementation-class=freemarker.build.FreemarkerRootPlugin
diff --git a/gradle/repositories.gradle.kts b/gradle/repositories.gradle.kts
new file mode 100644
index 0000000..dcf15f3
--- /dev/null
+++ b/gradle/repositories.gradle.kts
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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
+ *
+ *   http://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.
+ */
+
+fun RepositoryHandler.configurePluginRepositories() {
+    maven {
+        setUrl("https://repository.apache.org/content/groups/public")
+        mavenContent {
+            includeGroup("org.apache.freemarker.docgen")
+        }
+    }
+    gradlePluginPortal()
+    mavenCentral()
+}
+
+fun RepositoryHandler.configureMainRepositories() {
+    mavenCentral()
+}
+
+pluginManagement.repositories.configurePluginRepositories()
+
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+
+    if (rootDir.name == "buildSrc") {
+        repositories.configurePluginRepositories()
+    } else {
+        repositories.configureMainRepositories()
+    }
+}
diff --git a/rat-excludes b/rat-excludes
index 78069ea..5db1b24 100644
--- a/rat-excludes
+++ b/rat-excludes
@@ -50,6 +50,7 @@
 .ivy/**
 .bin/**
 bin/**
+buildSrc/build/**
 build/**
 .build/**
 out/**
diff --git a/settings.gradle.kts b/settings.gradle.kts
index f074a2b..aabfb19 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,23 +19,4 @@
 
 rootProject.name = "freemarker-gae"
 
-pluginManagement {
-    repositories {
-        maven {
-            setUrl("https://repository.apache.org/content/groups/public")
-            mavenContent {
-                includeGroup("org.apache.freemarker.docgen")
-            }
-        }
-        gradlePluginPortal()
-        mavenCentral()
-    }
-}
-
-dependencyResolutionManagement {
-    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
-
-    repositories {
-        mavenCentral()
-    }
-}
+apply(from = rootDir.toPath().resolve("gradle").resolve("repositories.gradle.kts"))