You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@logging.apache.org by vy...@apache.org on 2022/12/30 10:33:13 UTC

[logging-log4j-tools] branch master updated: Add FreeMarker support

This is an automated email from the ASF dual-hosted git repository.

vy pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/logging-log4j-tools.git


The following commit(s) were added to refs/heads/master by this push:
     new 94bec35  Add FreeMarker support
94bec35 is described below

commit 94bec35e689b3a547212fefb92d480a925e07f50
Author: Volkan Yazıcı <vo...@yazi.ci>
AuthorDate: Fri Dec 30 11:23:12 2022 +0100

    Add FreeMarker support
---
 log4j-changelog/README.adoc                        | 124 +++++--
 log4j-changelog/pom.xml                            |   7 +
 .../apache/logging/log4j/tools/CharsetUtils.java   |  30 ++
 .../org/apache/logging/log4j/tools/FileUtils.java  |   4 +-
 .../org/apache/logging/log4j/tools/XmlWriter.java  |   8 +-
 .../log4j/tools/changelog/ChangelogFiles.java      |  49 ++-
 .../tools/changelog/exporter/AsciiDocExporter.java | 402 ---------------------
 .../changelog/exporter/ChangelogExporter.java      | 194 ++++++++++
 ...xporterArgs.java => ChangelogExporterArgs.java} |   8 +-
 .../tools/changelog/exporter/FreeMarkerUtils.java  |  83 +++++
 .../changelog/releaser/ChangelogReleaser.java      |  37 +-
 log4j-tools-parent/pom.xml                         |   9 +
 12 files changed, 466 insertions(+), 489 deletions(-)

diff --git a/log4j-changelog/README.adoc b/log4j-changelog/README.adoc
index fe9a156..f965ad4 100644
--- a/log4j-changelog/README.adoc
+++ b/log4j-changelog/README.adoc
@@ -47,6 +47,7 @@ $ ls -a -1 src/changelog
 2.18.0
 2.19.0
 .2.x.x
+.index.adoc.ftl
 ----
 
 Changelog sources of _released versions_ are stored in `<changelogDirectory>/<releaseVersion>` folders (e.g., `/src/changelog/2.19.0`):
@@ -56,7 +57,7 @@ Changelog sources of _released versions_ are stored in `<changelogDirectory>/<re
 $ ls -a -1 src/changelog/2.19.0
 .
 ..
-.intro.adoc
+.changelog.adoc.ftl
 LOG4J2-2975_Add_implementation_of_SLF4J2_fluent_API.xml
 LOG4J2-3545_Add_correct_manifest_entries_for_OSGi_to_log4j_jcl.xml
 LOG4J2-3548_Improve_support_for_passwordless_keystores.xml
@@ -75,24 +76,35 @@ Changelog sources of _upcoming releases_ are stored in `<changelogDirectory>/.<r
 $ ls -a -1 src/changelog/.2.x.x
 .
 ..
+.changelog.adoc.ftl
 LOG4J2-2678_Add_LogEvent_timestamp_to_ProducerRecord_in_KafkaAppender.xml
 LOG4J2-3628_new_changelog_infra.xml
 LOG4J2-3631_Fix_Configurator_setLevel_for_internal_classes.xml
 LOG4J2-3634_Fix_level_propagation_in_Log4jBridgeHandler.xml
 ----
 
-A typical changelog entry looks as follows:
+A typical `.release.xml` looks as follows:
+
+[source]
+----
+$ cat src/changelog/2.19.0/release.xml
+<?xml version="1.0" encoding="UTF-8"?>
+<release date="2022-09-09" version="2.19.0"/>
+----
+
+A typical changelog entry file looks as follows:
 
 [source]
 ----
 $ cat src/changelog/.2.x.x/LOG4J2-3628_new_changelog_infra.xml
 <?xml version="1.0" encoding="UTF-8"?>
-<entry type="changed">
-  <issue id="LOG4J2-3628" link="https://issues.apache.org/jira/browse/LOG4J2-3628"/>
+<entry type="fixed">
+  <issue id="LOG4J2-3556" link="https://issues.apache.org/jira/browse/LOG4J2-3556"/>
   <author id="vy"/>
+  <author name="Arthur Gavlyukovskiy"/>
   <description format="asciidoc">
-    Replaced `maven-changes-plugin` with a custom changelog implementation
-    </description>
+    Make `JsonTemplateLayout` stack trace truncation operate for each label block
+  </description>
 </entry>
 ----
 
@@ -117,21 +129,21 @@ A released version changelog directory consists of following files:
 `.release.xml`::
 the meta information about the release (e.g., release version and date)
 
-`.intro.adoc`::
-the introductory content used by the AsciiDoc exporter
+`.changelog.adoc.ftl`::
+FreeMarker templated AsciiDoc file used by `ChangelogExporter`
 
 `[<issueId>_]<shortSummary>.xml`::
 changelog entry associated with a change
 
-The AsciiDoc exporter will compile these source files and output a `<releaseVersion>.adoc` (e.g., `2.19.0.adoc`) file for each release and an `index.adoc`.
+`ChangelogExporter` compiles these source files and outputs a `<releaseVersion>.adoc` (e.g., `2.19.0.adoc`) file for each release.
 
 [#unreleased-version-changelogs]
 == Unreleased version changelogs
 
 Changelogs of upcoming release versions are stored in `<changelogDirectory>/.<releaseVersionMajor>.x.x` directories (e.g., `/src/changelog/.2.x.x`).
-Compared to released version changelog directories (e.g., `2.19.0`), `.<releaseVersionMajor>.x.x` directories only consist of changelog entry files (i.e., `[<issueId>_]<shortSummary>.xml`).
+Compared to released version changelog directories (e.g., `2.19.0`), `.<releaseVersionMajor>.x.x` directories do not contain a `.release.xml`.
 
-The AsciiDoc exporter will compile these source files and output `<releaseVersionMajor>.x.x.adoc` (e.g., `2.x.x.adoc`) file for each upcoming release.
+`ChangelogExporter` compiles these source files and outputs `<releaseVersionMajor>.x.x.adoc` (e.g., `2.x.x.adoc`) file for each upcoming release.
 
 [#changelog-entry-file]
 == Changelog entry file
@@ -153,13 +165,15 @@ A sample _changelog entry_ file is shared below.
   <issue id="LOG4J2-3556" link="https://issues.apache.org/jira/browse/LOG4J2-3556"/>
   <author id="vy"/>
   <author name="Arthur Gavlyukovskiy"/>
-  <description format="asciidoc">Make `JsonTemplateLayout` stack trace truncation operate for each label block.</description>
+  <description format="asciidoc">
+    Make `JsonTemplateLayout` stack trace truncation operate for each label block
+  </description>
 </entry>
 ----
 
 Some remarks about the structure of changelog entry files:
 
-* The root element must be named `entry`.
+* The root element must be named `entry`
 * `entry.type` attribute is required and must be one of the https://keepachangelog.com/en/1.0.0/#how[_Keep a Changelog_ change types]:
 ** `added` – for new features
 ** `changed` – for changes in existing functionality
@@ -167,10 +181,65 @@ Some remarks about the structure of changelog entry files:
 ** `removed` – for now removed features
 ** `fixed` – for any bug fixes
 ** `security` – for vulnerabilities
-* `issue` element is optional, and, if present, must contain `id` and `link` attributes.
-* `author` element must have at least one of `id` or `name` attributes.
-* There must be at least one author.
-* There must be a single `description` element with non-blank content and `format="asciidoc"` attribute.
+* `issue` element is optional, and, if present, must contain `id` and `link` attributes
+* `author` element must have at least one of `id` or `name` attributes
+* There must be at least one `author`
+* There must be a single `description` element with non-blank content and `format="asciidoc"` attribute
+
+[#changelog-template-file]
+== Changelog template file
+
+Each `.changelog.adoc.ftl` FreeMarker templated AsciiDoc files are compiled by `ChangelogExporter` with the following input data hash:
+
+* `release` -> `ChangelogRelease`
+* `entriesByType` -> `Map<ChangelogEntry.Type, List<ChangelogEntry>>`
+
+See xref:src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogRelease.java[ChangelogRelease.java] and xref:src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogEntry.java[ChangelogEntry.java] for details.
+
+A sample changelog template file is shared below.
+
+.`src/changelog/2.19.0/.changelog.adoc.ftl` file contents
+[source,asciidoc]
+----
+= ${release.version}<#if release.date?has_content> (${release.date})</#if>
+
+Changes staged for the next version that is yet to be released.
+
+<#if entriesByType?size gt 0>== Changes
+<#list entriesByType as entryType, entries>
+
+=== ${entryType?capitalize}
+
+<#list entries as entry>
+* ${entry.description.text?replace("\\s+", " ", "r")}
+(for <#list entry.issues as issue>${issue.link}[${issue.id}]<#if issue?has_next>, </#if></#list>
+by <#list entry.authors as author><#if author.name?has_content>${author.name}<#else>`${author.id}`</#if><#if author?has_next>, </#if></#list>)
+</#list>
+</#list>
+</#if>
+----
+
+[#index-template-file]
+== Index template file
+
+`.index.adoc.ftl` FreeMarker templated AsciiDoc file is compiled by `ChangelogExporter` with the following input data hash:
+
+* `releases` -> list of hashes containing following keys:
+** `version`
+** `date`
+** `changelogFileName`
+
+A sample index template file is shared below.
+
+.`src/changelog/.index.adoc.ftl` file contents
+[source,asciidoc]
+----
+= Release changelogs
+
+<#list releases as release>
+* xref:${release.changelogFileName}[${release.version}]<#if release.date?has_content> (${release.date})</#if>
+</#list>
+----
 
 [#qa]
 == Q&A
@@ -184,7 +253,7 @@ Simply create a <<#changelog-entry-file>> and commit it along with your change!
 [#qa-generate]
 === How can I export changelogs to AsciiDoc files?
 
-You need to use `AsciiDocExporter` as follows:
+You need to use `ChangelogExporter` as follows:
 
 [source,bash]
 ----
@@ -192,16 +261,17 @@ java \
     -cp /path/to/log4j-changelog.jar \
     -Dlog4j.changelog.directory=/path/to/changelog/directory \
     -Dlog4j.changelog.exporter.outputDirectory=/path/to/asciiDocOutputDirectory \
-    org.apache.logging.log4j.tools.changelog.exporter.AsciiDocExporter
+    org.apache.logging.log4j.tools.changelog.exporter.ChangelogExporter
 ----
 
 [#qa-deploy-release]
 === I am about to deploy a new Log4j release. What shall I do?
 
-Just before a release, two things need to happen in the changelog sources:
+Just before a release, three things need to happen in the changelog sources:
 
-. Changelog entries of the upcoming release directory `<changelogDirectory>/.<releaseVersionMajor>.x.x` needs to be moved to the release changelog directory `<changelogDirectory>/<releaseVersion>`
-. `.index.adoc` and `.release.xml` need to be created in the release changelog directory `<changelogDirectory>/<releaseVersion>`
+. *Changelog entry files needs to be moved* from the _upcoming_ release changelog directory `<changelogDirectory>/.<releaseVersionMajor>.x.x`  to the _new_ release changelog directory `<changelogDirectory>/<releaseVersion>`
+. *`.changelog.adoc.ftl` needs to be copied* from the _upcoming_ release changelog directory to the _new_ release changelog directory, unless it already exists in the target
+. *`.release.xml` needs to be created* in the _new_ release changelog directory
 
 Due to the nature of release candidates, above steps might need to be repeated multiple times.
 
@@ -214,7 +284,7 @@ Once a release candidate voting reaches to a consensus for release, associated a
 Hence, there are no differences between releases and release candidates.
 ====
 
-How to carry out aforementioned two changes are explained below in steps:
+How to carry out aforementioned changes are explained below in steps:
 
 . Populate the `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) from the upcoming release changelog directory (e.g., `<changelogDirectory>/.2.x.x`):
 +
@@ -226,13 +296,13 @@ java \
     -Dlog4j.changelog.releaseVersion=X.Y.Z \
     org.apache.logging.log4j.tools.changelog.releaser.ChangelogReleaser
 ----
-. Verify that `<changelogDirectory>/.<releaseVersionMajor>.x.x` directory (e.g., `/src/changelog/.2.x.x`) is emptied
-. Verify that `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) is created, and it contains `.intro.adoc`, `.release.xml`, and changelog entry files
+. Verify that all changelog entry files are moved from `<changelogDirectory>/.<releaseVersionMajor>.x.x` directory (e.g., `/src/changelog/.2.x.x`)
+. Verify that `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) is created, and it contains `.changelog.adoc.ftl`, `.release.xml`, and changelog entry files
 +
 [IMPORTANT]
 ====
-If `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) already exists with certain content, `ChangelogReleaser` will only move new changelog entry files and override `.release.xml`; `.intro.adoc` will not be touched, if exists.
+If `<changelogDirectory>/<releaseVersion>` directory (e.g., `/src/changelog/2.19.0`) already exists with certain content, `ChangelogReleaser` will only move new changelog entry files and override `.release.xml`; `.changelog.adoc.ftl` will not be touched, if it already exists.
 This allows one to run `ChangelogReleaser` multiple times, e.g., to incorporate changes added to a release candidate.
 ====
-. Edit the created `.intro.adoc`
+. Edit the populated `.changelog.adoc.ftl`
 . `git add` the changes in the changelog directory (e.g., `/src/changelog`) and commit them
diff --git a/log4j-changelog/pom.xml b/log4j-changelog/pom.xml
index bd71dd0..30f29aa 100644
--- a/log4j-changelog/pom.xml
+++ b/log4j-changelog/pom.xml
@@ -31,11 +31,18 @@
   <artifactId>log4j-changelog</artifactId>
 
   <dependencies>
+
     <dependency>
       <groupId>com.github.spotbugs</groupId>
       <artifactId>spotbugs-annotations</artifactId>
       <scope>provided</scope>
     </dependency>
+
+    <dependency>
+      <groupId>org.freemarker</groupId>
+      <artifactId>freemarker</artifactId>
+    </dependency>
+
   </dependencies>
 
 </project>
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/CharsetUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/CharsetUtils.java
new file mode 100644
index 0000000..f23df9e
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/CharsetUtils.java
@@ -0,0 +1,30 @@
+/*
+ * 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 org.apache.logging.log4j.tools;
+
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+
+public final class CharsetUtils {
+
+    private CharsetUtils() {}
+
+    public static final Charset CHARSET = StandardCharsets.UTF_8;
+
+    public static final String CHARSET_NAME = CHARSET.name();
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/FileUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/FileUtils.java
index 41a3d57..555c250 100644
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/FileUtils.java
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/FileUtils.java
@@ -33,7 +33,7 @@ public final class FileUtils {
      * </p>
      */
     @SuppressWarnings("RedundantIfStatement")
-    public static Stream<Path> findAdjacentFiles(final Path directory) {
+    public static Stream<Path> findAdjacentFiles(final Path directory, final boolean dotFilesSkipped) {
         try {
             return Files
                     .walk(directory, 1)
@@ -45,7 +45,7 @@ public final class FileUtils {
                         }
 
                         // Skip hidden files.
-                        boolean hiddenFile = path.getFileName().toString().startsWith(".");
+                        boolean hiddenFile = dotFilesSkipped && path.getFileName().toString().startsWith(".");
                         if (hiddenFile) {
                             return false;
                         }
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/XmlWriter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/XmlWriter.java
index 78a0f59..8a72c3d 100644
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/XmlWriter.java
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/XmlWriter.java
@@ -17,8 +17,6 @@
 package org.apache.logging.log4j.tools;
 
 import java.io.StringWriter;
-import java.nio.charset.Charset;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
@@ -38,14 +36,12 @@ import org.w3c.dom.Document;
 
 public final class XmlWriter {
 
-    private static final Charset ENCODING = StandardCharsets.UTF_8;
-
     private XmlWriter() {}
 
     public static void toFile(final Path filepath, final Consumer<Document> documentConsumer) {
         try {
             final String xml = toString(documentConsumer);
-            final byte[] xmlBytes = xml.getBytes(ENCODING);
+            final byte[] xmlBytes = xml.getBytes(CharsetUtils.CHARSET);
             Path filepathParent = filepath.getParent();
             if (filepathParent != null) {
                 Files.createDirectories(filepathParent);
@@ -101,7 +97,7 @@ public final class XmlWriter {
         final Transformer transformer = TransformerFactory.newInstance().newTransformer();
         final StreamResult result = new StreamResult(new StringWriter());
         final DOMSource source = new DOMSource(document);
-        transformer.setOutputProperty(OutputKeys.ENCODING, ENCODING.name());
+        transformer.setOutputProperty(OutputKeys.ENCODING, CharsetUtils.CHARSET_NAME);
         transformer.setOutputProperty(OutputKeys.INDENT, "yes");
         transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
         transformer.transform(source, result);
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogFiles.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogFiles.java
index 20b0b6e..f4a90aa 100644
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogFiles.java
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/ChangelogFiles.java
@@ -16,9 +16,6 @@
  */
 package org.apache.logging.log4j.tools.changelog;
 
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Set;
 import java.util.regex.Matcher;
@@ -26,6 +23,8 @@ import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
+import org.apache.logging.log4j.tools.FileUtils;
+
 public final class ChangelogFiles {
 
     private ChangelogFiles() {}
@@ -36,32 +35,26 @@ public final class ChangelogFiles {
     }
 
     public static Set<Integer> unreleasedDirectoryVersionMajors(final Path changelogDirectory) {
-        try {
-            return Files
-                    .walk(changelogDirectory, 1)
-                    .flatMap(path -> {
+        return FileUtils
+                .findAdjacentFiles(changelogDirectory, false)
+                .flatMap(path -> {
 
-                        // Skip the directory itself.
-                        if (path.equals(changelogDirectory)) {
-                            return Stream.empty();
-                        }
+                    // Only select directories matching with the `^\.(\d+)\.x\.x$` pattern.
+                    final Pattern versionPattern = Pattern.compile("^\\.(\\d+)\\.x\\.x$");
+                    final Matcher versionMatcher = versionPattern.matcher(path.getFileName().toString());
+                    if (!versionMatcher.matches()) {
+                        return Stream.empty();
+                    }
+                    final String versionMajorString = versionMatcher.group(1);
+                    final int versionMajor = Integer.parseInt(versionMajorString);
+                    return Stream.of(versionMajor);
 
-                        // Only select directories matching with the `^\.(\d+)\.x\.x$` pattern.
-                        final Pattern versionPattern = Pattern.compile("^\\.(\\d+)\\.x\\.x$");
-                        final Matcher versionMatcher = versionPattern.matcher(path.getFileName().toString());
-                        if (!versionMatcher.matches()) {
-                            return Stream.empty();
-                        }
-                        final String versionMajorString = versionMatcher.group(1);
-                        final int versionMajor = Integer.parseInt(versionMajorString);
-                        return Stream.of(versionMajor);
+                })
+                .collect(Collectors.toSet());
+    }
 
-                    })
-                    .collect(Collectors.toSet());
-        } catch (final IOException error) {
-            final String message = String.format("failed walking directory: `%s`", changelogDirectory);
-            throw new UncheckedIOException(message, error);
-        }
+    public static Path indexTemplateFile(final Path changelogDirectory) {
+        return changelogDirectory.resolve(".index.adoc.ftl");
     }
 
     public static Path releaseDirectory(final Path changelogDirectory, final String releaseVersion) {
@@ -72,8 +65,8 @@ public final class ChangelogFiles {
         return releaseDirectory.resolve(".release.xml");
     }
 
-    public static Path introAsciiDocFile(final Path releaseDirectory) {
-        return releaseDirectory.resolve(".intro.adoc");
+    public static Path releaseChangelogTemplateFile(final Path releaseDirectory) {
+        return releaseDirectory.resolve(".changelog.adoc.ftl");
     }
 
 }
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporter.java
deleted file mode 100644
index 23220dd..0000000
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporter.java
+++ /dev/null
@@ -1,402 +0,0 @@
-/*
- * 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 org.apache.logging.log4j.tools.changelog.exporter;
-
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.stream.Collectors;
-
-import org.apache.logging.log4j.tools.AsciiDocUtils;
-import org.apache.logging.log4j.tools.FileUtils;
-import org.apache.logging.log4j.tools.changelog.ChangelogEntry;
-import org.apache.logging.log4j.tools.changelog.ChangelogFiles;
-import org.apache.logging.log4j.tools.changelog.ChangelogRelease;
-
-import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
-
-public final class AsciiDocExporter {
-
-    private static final String AUTO_GENERATION_WARNING_ASCIIDOC = "////\n" +
-            "*DO NOT EDIT THIS FILE!*\n" +
-            "This file is automatically generated from the release changelog directory!\n" +
-            "////\n";
-
-    private AsciiDocExporter() {}
-
-    public static void main(final String[] mainArgs) {
-
-        // Read arguments.
-        final AsciiDocExporterArgs args = AsciiDocExporterArgs.fromSystemProperties();
-
-        // Find release directories.
-        final List<Path> releaseDirectories = FileUtils
-                .findAdjacentFiles(args.changelogDirectory)
-                .filter(file -> file.toFile().isDirectory())
-                .sorted(Comparator.comparing(releaseDirectory -> {
-                    final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
-                    final ChangelogRelease changelogRelease = ChangelogRelease.readFromXmlFile(releaseXmlFile);
-                    return changelogRelease.date;
-                }))
-                .collect(Collectors.toList());
-        final int releaseDirectoryCount = releaseDirectories.size();
-
-        // Read the release information files.
-        final List<ChangelogRelease> changelogReleases = releaseDirectories
-                .stream()
-                .map(releaseDirectory -> {
-                    final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
-                    return ChangelogRelease.readFromXmlFile(releaseXmlFile);
-                })
-                .collect(Collectors.toList());
-
-        // Export releases.
-        if (releaseDirectoryCount > 0) {
-
-            // Export each release directory.
-            for (int releaseIndex = 0; releaseIndex < releaseDirectories.size(); releaseIndex++) {
-                final Path releaseDirectory = releaseDirectories.get(releaseIndex);
-                final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
-                try {
-                    exportRelease(args.outputDirectory, releaseDirectory, changelogRelease);
-                } catch (final Exception error) {
-                    final String message =
-                            String.format("failed exporting release from directory `%s`", releaseDirectory);
-                    throw new RuntimeException(message, error);
-                }
-            }
-
-            // Report the operation.
-            if (releaseDirectoryCount == 1) {
-                System.out.format("exported a single release directory: `%s`%n", releaseDirectories.get(0));
-            } else {
-                System.out.format(
-                        "exported %d release directories: ..., `%s`%n",
-                        releaseDirectories.size(),
-                        releaseDirectories.get(releaseDirectoryCount - 1));
-            }
-
-        }
-
-        // Export unreleased.
-        ChangelogFiles
-                .unreleasedDirectoryVersionMajors(args.changelogDirectory)
-                .stream()
-                .sorted(Comparator.reverseOrder())
-                .forEach(upcomingReleaseVersionMajor -> {
-                    final Path upcomingReleaseDirectory =
-                            ChangelogFiles.unreleasedDirectory(args.changelogDirectory, upcomingReleaseVersionMajor);
-                    final ChangelogRelease upcomingRelease = upcomingRelease(upcomingReleaseVersionMajor);
-                    System.out.format("exporting upcoming release directory: `%s`%n", upcomingReleaseDirectory);
-                    exportUnreleased(args.outputDirectory, upcomingReleaseDirectory, upcomingRelease);
-                    changelogReleases.add(upcomingRelease);
-                });
-
-        // Export the release index.
-        exportReleaseIndex(args.outputDirectory, changelogReleases);
-
-    }
-
-    private static void exportRelease(
-            final Path outputDirectory,
-            final Path releaseDirectory,
-            final ChangelogRelease changelogRelease) {
-        final String introAsciiDoc = readIntroAsciiDoc(releaseDirectory);
-        final List<ChangelogEntry> changelogEntries = readChangelogEntries(releaseDirectory);
-        try {
-            exportRelease(outputDirectory, changelogRelease, introAsciiDoc, changelogEntries);
-        } catch (final IOException error) {
-            final String message = String.format("failed exporting release from directory `%s`", releaseDirectory);
-            throw new UncheckedIOException(message, error);
-        }
-    }
-
-    private static String readIntroAsciiDoc(final Path releaseDirectory) {
-
-        // Determine the file to be read.
-        final Path introAsciiDocFile = ChangelogFiles.introAsciiDocFile(releaseDirectory);
-        if (!Files.exists(introAsciiDocFile)) {
-            return "";
-        }
-
-        // Read the file.
-        final List<String> introAsciiDocLines;
-        try {
-            introAsciiDocLines = Files.readAllLines(introAsciiDocFile, StandardCharsets.UTF_8);
-        } catch (final IOException error) {
-            final String message = String.format("failed reading intro AsciiDoc file: `%s`", introAsciiDocFile);
-            throw new UncheckedIOException(message, error);
-        }
-
-        // Erase comment blocks.
-        final boolean[] inCommentBlock = {false};
-        return introAsciiDocLines
-                .stream()
-                .filter(line -> {
-                    final boolean commentBlock = "////".equals(line);
-                    if (commentBlock) {
-                        inCommentBlock[0] = !inCommentBlock[0];
-                        return false;
-                    }
-                    return !inCommentBlock[0];
-                })
-                .collect(Collectors.joining("\n"))
-                + "\n";
-
-    }
-
-    private static List<ChangelogEntry> readChangelogEntries(final Path releaseDirectory) {
-        return FileUtils
-                .findAdjacentFiles(releaseDirectory)
-                // Sorting is needed to generate the same output between different runs.
-                .sorted()
-                .map(ChangelogEntry::readFromXmlFile)
-                .collect(Collectors.toList());
-    }
-
-    private static void exportRelease(
-            final Path outputDirectory,
-            final ChangelogRelease release,
-            final String introAsciiDoc,
-            final List<ChangelogEntry> entries)
-            throws IOException {
-        final String asciiDocFilename = changelogReleaseAsciiDocFilename(release);
-        final Path asciiDocFile = outputDirectory.resolve(asciiDocFilename);
-        Path asciiDocFileParent = asciiDocFile.getParent();
-        if (asciiDocFileParent != null) {
-            Files.createDirectories(asciiDocFileParent);
-        }
-        final String asciiDoc = exportReleaseToAsciiDoc(release, introAsciiDoc, entries);
-        final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
-        Files.write(asciiDocFile, asciiDocBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
-    }
-
-    private static String exportReleaseToAsciiDoc(
-            final ChangelogRelease release,
-            final String introAsciiDoc,
-            final List<ChangelogEntry> entries) {
-
-        // Write the header.
-        final StringBuilder stringBuilder = new StringBuilder();
-        stringBuilder
-                .append(AsciiDocUtils.LICENSE_COMMENT_BLOCK)
-                .append('\n')
-                .append(AUTO_GENERATION_WARNING_ASCIIDOC)
-                .append('\n')
-                .append("= ")
-                .append(release.version);
-        if (release.date != null) {
-            stringBuilder
-                    .append(" (")
-                    .append(release.date)
-                    .append(")\n")
-                    .append(introAsciiDoc)
-                    .append("\n");
-        } else {
-            stringBuilder.append("\n\nChanges staged for the next version that is yet to be released.\n\n");
-        }
-
-        if (!entries.isEmpty()) {
-
-            stringBuilder.append("== Changes\n");
-
-            // Group entries by type.
-            final Map<ChangelogEntry.Type, List<ChangelogEntry>> entriesByType = entries
-                    .stream()
-                    .collect(Collectors.groupingBy(changelogEntry -> changelogEntry.type));
-
-            // Write entries for each type.
-            entriesByType
-                    .keySet()
-                    .stream()
-                    // Sorting is necessary for a consistent layout across different runs.
-                    .sorted()
-                    .forEach(type -> {
-                        stringBuilder.append('\n');
-                        appendEntryTypeHeader(stringBuilder, type);
-                        entriesByType.get(type).forEach(entry -> appendEntry(stringBuilder, entry));
-                    });
-
-        }
-
-        // Return the accumulated document so far.
-        return stringBuilder.toString();
-
-    }
-
-    private static void appendEntryTypeHeader(final StringBuilder stringBuilder, final ChangelogEntry.Type type) {
-        final String typeName = type.toString().toLowerCase(Locale.US);
-        final String header = typeName.substring(0, 1).toUpperCase(Locale.US) + typeName.substring(1);
-        stringBuilder
-                .append("=== ")
-                .append(header)
-                .append("\n\n");
-    }
-
-    private static void appendEntry(final StringBuilder stringBuilder, final ChangelogEntry entry) {
-        stringBuilder.append("* ");
-        appendEntryDescription(stringBuilder, entry.description);
-        final boolean containingIssues = !entry.issues.isEmpty();
-        final boolean containingAuthors = !entry.authors.isEmpty();
-        if (containingIssues || containingAuthors) {
-            stringBuilder.append(" (");
-            if (containingIssues) {
-                appendEntryIssues(stringBuilder, entry.issues);
-            }
-            if (containingIssues && containingAuthors) {
-                stringBuilder.append(' ');
-            }
-            if (containingAuthors) {
-                appendEntryAuthors(stringBuilder, entry.authors);
-            }
-            stringBuilder.append(")");
-        }
-        stringBuilder.append('\n');
-    }
-
-    private static void appendEntryDescription(
-            final StringBuilder stringBuilder,
-            final ChangelogEntry.Description description) {
-        if (!"asciidoc".equals(description.format)) {
-            final String message = String.format("unsupported description format: `%s`", description.format);
-            throw new RuntimeException(message);
-        }
-        stringBuilder.append(description.text);
-    }
-
-    private static void appendEntryIssues(
-            final StringBuilder stringBuilder,
-            final List<ChangelogEntry.Issue> issues) {
-        stringBuilder.append("for ");
-        final int issueCount = issues.size();
-        for (int issueIndex = 0; issueIndex < issueCount; issueIndex++) {
-            final ChangelogEntry.Issue issue = issues.get(issueIndex);
-            appendEntryIssue(stringBuilder, issue);
-            if ((issueIndex + 1) != issueCount) {
-                stringBuilder.append(", ");
-            }
-        }
-    }
-
-    private static void appendEntryIssue(final StringBuilder stringBuilder, final ChangelogEntry.Issue issue) {
-        stringBuilder
-                .append(issue.link)
-                .append('[')
-                .append(issue.id)
-                .append(']');
-    }
-
-    private static void appendEntryAuthors(
-            final StringBuilder stringBuilder,
-            final List<ChangelogEntry.Author> authors) {
-        stringBuilder.append("by ");
-        final int authorCount = authors.size();
-        for (int authorIndex = 0; authorIndex < authors.size(); authorIndex++) {
-            final ChangelogEntry.Author author = authors.get(authorIndex);
-            appendEntryAuthor(stringBuilder, author);
-            if ((authorIndex + 1) != authorCount) {
-                stringBuilder.append(", ");
-            }
-        }
-    }
-
-    private static void appendEntryAuthor(final StringBuilder stringBuilder, final ChangelogEntry.Author author) {
-        if (author.id != null) {
-            stringBuilder
-                    .append('`')
-                    .append(author.id)
-                    .append('`');
-        } else {
-            // Normalize author names written in `Doe, John` form.
-            if (author.name.contains(",")) {
-                String[] nameFields = author.name.split(",", 2);
-                stringBuilder.append(nameFields[1].trim());
-                stringBuilder.append(nameFields[0].trim());
-            } else {
-                stringBuilder.append(author.name);
-            }
-        }
-    }
-
-    private static void exportUnreleased(
-            final Path outputDirectory,
-            final Path upcomingReleaseDirectory,
-            final ChangelogRelease upcomingRelease) {
-        final List<ChangelogEntry> changelogEntries = readChangelogEntries(upcomingReleaseDirectory);
-        try {
-            exportRelease(outputDirectory, upcomingRelease, null, changelogEntries);
-        } catch (final IOException error) {
-            throw new UncheckedIOException("failed exporting unreleased changes", error);
-        }
-    }
-
-    private static ChangelogRelease upcomingRelease(final int versionMajor) {
-        final String releaseVersion = versionMajor + ".x.x";
-        return new ChangelogRelease(releaseVersion, null);
-    }
-
-    private static void exportReleaseIndex(
-            final Path outputDirectory,
-            final List<ChangelogRelease> changelogReleases) {
-        final String asciiDoc = exportReleaseIndexToAsciiDoc(changelogReleases);
-        final byte[] asciiDocBytes = asciiDoc.getBytes(StandardCharsets.UTF_8);
-        final Path asciiDocFile = outputDirectory.resolve("index.adoc");
-        System.out.format("exporting release index to `%s`%n", asciiDocFile);
-        try {
-            Files.write(asciiDocFile, asciiDocBytes);
-        } catch (final IOException error) {
-            throw new UncheckedIOException(error);
-        }
-    }
-
-    @SuppressFBWarnings("VA_FORMAT_STRING_USES_NEWLINE")
-    private static String exportReleaseIndexToAsciiDoc(final List<ChangelogRelease> changelogReleases) {
-        final StringBuilder stringBuilder = new StringBuilder();
-        stringBuilder
-                .append(AsciiDocUtils.LICENSE_COMMENT_BLOCK)
-                .append('\n')
-                .append(AUTO_GENERATION_WARNING_ASCIIDOC)
-                .append("\n= Release changelogs\n\n");
-        for (int releaseIndex = changelogReleases.size() - 1; releaseIndex >= 0; releaseIndex--) {
-            final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
-            final String asciiDocFilename = changelogReleaseAsciiDocFilename(changelogRelease);
-            final String asciiDocBulletDateSuffix = changelogRelease.date != null
-                    ? (" (" + changelogRelease.date + ')')
-                    : "";
-            final String asciiDocBullet = String.format(
-                    "* xref:%s[%s]%s\n",
-                    asciiDocFilename,
-                    changelogRelease.version,
-                    asciiDocBulletDateSuffix);
-            stringBuilder.append(asciiDocBullet);
-        }
-        return stringBuilder.toString();
-    }
-
-    private static String changelogReleaseAsciiDocFilename(final ChangelogRelease changelogRelease) {
-        // Using only the version (that is, avoiding the date) in the filename so that one can determine the link to the changelog of a particular release with only version information.
-        return String.format("%s.adoc", changelogRelease.version);
-    }
-
-}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporter.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporter.java
new file mode 100644
index 0000000..b274c0c
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporter.java
@@ -0,0 +1,194 @@
+/*
+ * 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 org.apache.logging.log4j.tools.changelog.exporter;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.apache.logging.log4j.tools.FileUtils;
+import org.apache.logging.log4j.tools.changelog.ChangelogEntry;
+import org.apache.logging.log4j.tools.changelog.ChangelogFiles;
+import org.apache.logging.log4j.tools.changelog.ChangelogRelease;
+
+public final class ChangelogExporter {
+
+    private ChangelogExporter() {}
+
+    public static void main(final String[] mainArgs) {
+
+        // Read arguments.
+        final ChangelogExporterArgs args = ChangelogExporterArgs.fromSystemProperties();
+
+        // Find release directories.
+        final List<Path> releaseDirectories = FileUtils
+                .findAdjacentFiles(args.changelogDirectory, true)
+                .filter(file -> file.toFile().isDirectory())
+                .sorted(Comparator.comparing(releaseDirectory -> {
+                    final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+                    final ChangelogRelease changelogRelease = ChangelogRelease.readFromXmlFile(releaseXmlFile);
+                    return changelogRelease.date;
+                }))
+                .collect(Collectors.toList());
+        final int releaseDirectoryCount = releaseDirectories.size();
+
+        // Read the release information files.
+        final List<ChangelogRelease> changelogReleases = releaseDirectories
+                .stream()
+                .map(releaseDirectory -> {
+                    final Path releaseXmlFile = ChangelogFiles.releaseXmlFile(releaseDirectory);
+                    return ChangelogRelease.readFromXmlFile(releaseXmlFile);
+                })
+                .collect(Collectors.toList());
+
+        // Export releases.
+        if (releaseDirectoryCount > 0) {
+
+            // Export each release directory.
+            for (int releaseIndex = 0; releaseIndex < releaseDirectories.size(); releaseIndex++) {
+                final Path releaseDirectory = releaseDirectories.get(releaseIndex);
+                final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+                final Path releaseChangelogTemplateFile = ChangelogFiles.releaseChangelogTemplateFile(releaseDirectory);
+                try {
+                    exportRelease(
+                            args.outputDirectory,
+                            releaseDirectory,
+                            changelogRelease,
+                            releaseChangelogTemplateFile);
+                } catch (final Exception error) {
+                    final String message =
+                            String.format("failed exporting release from directory `%s`", releaseDirectory);
+                    throw new RuntimeException(message, error);
+                }
+            }
+
+            // Report the operation.
+            if (releaseDirectoryCount == 1) {
+                System.out.format("exported a single release directory: `%s`%n", releaseDirectories.get(0));
+            } else {
+                System.out.format(
+                        "exported %d release directories: ..., `%s`%n",
+                        releaseDirectories.size(),
+                        releaseDirectories.get(releaseDirectoryCount - 1));
+            }
+
+        }
+
+        // Export unreleased.
+        ChangelogFiles
+                .unreleasedDirectoryVersionMajors(args.changelogDirectory)
+                .stream()
+                .sorted(Comparator.reverseOrder())
+                .forEach(upcomingReleaseVersionMajor -> {
+                    final Path upcomingReleaseDirectory =
+                            ChangelogFiles.unreleasedDirectory(args.changelogDirectory, upcomingReleaseVersionMajor);
+                    final ChangelogRelease upcomingRelease = upcomingRelease(upcomingReleaseVersionMajor);
+                    final Path upcomingReleaseChangelogTemplateFile =
+                            ChangelogFiles.releaseChangelogTemplateFile(upcomingReleaseDirectory);
+                    System.out.format("exporting upcoming release directory: `%s`%n", upcomingReleaseDirectory);
+                    exportRelease(
+                            args.outputDirectory,
+                            upcomingReleaseDirectory,
+                            upcomingRelease,
+                            upcomingReleaseChangelogTemplateFile);
+                    changelogReleases.add(upcomingRelease);
+                });
+
+        // Export the release index.
+        final Path changelogIndexTemplateFile = ChangelogFiles.indexTemplateFile(args.changelogDirectory);
+        exportIndex(args.outputDirectory, changelogReleases, changelogIndexTemplateFile);
+
+    }
+
+    private static void exportRelease(
+            final Path outputDirectory,
+            final Path releaseDirectory,
+            final ChangelogRelease changelogRelease,
+            final Path releaseChangelogTemplateFile) {
+        final Map<ChangelogEntry.Type, List<ChangelogEntry>> changelogEntriesByType = readChangelogEntriesByType(releaseDirectory);
+        try {
+            exportRelease(outputDirectory, changelogRelease, changelogEntriesByType, releaseChangelogTemplateFile);
+        } catch (final IOException error) {
+            final String message = String.format("failed exporting release from directory `%s`", releaseDirectory);
+            throw new UncheckedIOException(message, error);
+        }
+    }
+
+    private static Map<ChangelogEntry.Type, List<ChangelogEntry>> readChangelogEntriesByType(
+            final Path releaseDirectory) {
+        return FileUtils
+                .findAdjacentFiles(releaseDirectory, true)
+                // Sorting is needed to generate the same output between different runs
+                .sorted()
+                .map(ChangelogEntry::readFromXmlFile)
+                .collect(Collectors.groupingBy(
+                        changelogEntry -> changelogEntry.type,
+                        // A sorted map is needed to generate the same output between different runs
+                        TreeMap::new,
+                        Collectors.toList()));
+    }
+
+    private static void exportRelease(
+            final Path outputDirectory,
+            final ChangelogRelease release,
+            final Map<ChangelogEntry.Type, List<ChangelogEntry>> entriesByType,
+            final Path releaseChangelogTemplateFile)
+            throws IOException {
+        final String releaseChangelogFileName = releaseChangelogFileName(release);
+        final Path releaseChangelogFile = outputDirectory.resolve(releaseChangelogFileName);
+        final Map<String, Object> releaseChangelogTemplateData = new LinkedHashMap<>();
+        releaseChangelogTemplateData.put("release", release);
+        releaseChangelogTemplateData.put("entriesByType", entriesByType);
+        FreeMarkerUtils.render(releaseChangelogTemplateFile, releaseChangelogTemplateData, releaseChangelogFile);
+    }
+
+    private static ChangelogRelease upcomingRelease(final int versionMajor) {
+        final String releaseVersion = versionMajor + ".x.x";
+        return new ChangelogRelease(releaseVersion, null);
+    }
+
+    private static void exportIndex(
+            final Path outputDirectory,
+            final List<ChangelogRelease> changelogReleases,
+            final Path indexTemplateFile) {
+        final Object indexTemplateData = Collections.singletonMap(
+                "releases", IntStream
+                        .range(0, changelogReleases.size())
+                        .boxed()
+                        .sorted(Comparator.reverseOrder())
+                        .map(releaseIndex -> {
+                            final ChangelogRelease changelogRelease = changelogReleases.get(releaseIndex);
+                            Map<String, Object> changelogReleaseData = new LinkedHashMap<>();
+                            changelogReleaseData.put("version", changelogRelease.version);
+                            changelogReleaseData.put("date", changelogRelease.date);
+                            changelogReleaseData.put("changelogFileName", releaseChangelogFileName(changelogRelease));
+                            return (Object) changelogReleaseData;
+                        })
+                        .collect(Collectors.toList()));
+        final Path indexFile = outputDirectory.resolve("index.adoc");
+        FreeMarkerUtils.render(indexTemplateFile, indexTemplateData, indexFile);
+    }
+
+    private static String releaseChangelogFileName(final ChangelogRelease changelogRelease) {
+        // Using only the version (that is, avoiding the date) in the filename so that one can determine the link to the changelog of a particular release with only version information.
+        return String.format("%s.adoc", changelogRelease.version);
+    }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporterArgs.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporterArgs.java
similarity index 83%
rename from log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporterArgs.java
rename to log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporterArgs.java
index 05cdd80..371985f 100644
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/AsciiDocExporterArgs.java
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/ChangelogExporterArgs.java
@@ -20,21 +20,21 @@ import java.nio.file.Path;
 
 import static org.apache.logging.log4j.tools.PropertyUtils.requireNonBlankPathProperty;
 
-final class AsciiDocExporterArgs {
+final class ChangelogExporterArgs {
 
     final Path changelogDirectory;
 
     final Path outputDirectory;
 
-    private AsciiDocExporterArgs(final Path changelogDirectory, final Path outputDirectory) {
+    private ChangelogExporterArgs(final Path changelogDirectory, final Path outputDirectory) {
         this.changelogDirectory = changelogDirectory;
         this.outputDirectory = outputDirectory;
     }
 
-    static AsciiDocExporterArgs fromSystemProperties() {
+    static ChangelogExporterArgs fromSystemProperties() {
         final Path changelogDirectory = requireNonBlankPathProperty("log4j.changelog.directory");
         final Path outputDirectory = requireNonBlankPathProperty("log4j.changelog.exporter.outputDirectory");
-        return new AsciiDocExporterArgs(changelogDirectory, outputDirectory);
+        return new ChangelogExporterArgs(changelogDirectory, outputDirectory);
     }
 
 }
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/FreeMarkerUtils.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/FreeMarkerUtils.java
new file mode 100644
index 0000000..1275809
--- /dev/null
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/exporter/FreeMarkerUtils.java
@@ -0,0 +1,83 @@
+/*
+ * 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 org.apache.logging.log4j.tools.changelog.exporter;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+
+import org.apache.logging.log4j.tools.CharsetUtils;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+import freemarker.cache.FileTemplateLoader;
+import freemarker.template.*;
+
+final class FreeMarkerUtils {
+
+    private FreeMarkerUtils() {}
+
+    private static final Configuration CONFIGURATION = createConfiguration();
+
+    @SuppressFBWarnings("DMI_HARDCODED_ABSOLUTE_FILENAME")
+    private static Configuration createConfiguration() {
+        Configuration configuration = new Configuration(Configuration.VERSION_2_3_29);
+        configuration.setDefaultEncoding(CharsetUtils.CHARSET_NAME);
+        configuration.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
+        try {
+            configuration.setTemplateLoader(new FileTemplateLoader(new File("/")));
+        } catch (IOException error) {
+            throw new UncheckedIOException(error);
+        }
+        DefaultObjectWrapperBuilder objectWrapperBuilder = new DefaultObjectWrapperBuilder(Configuration.VERSION_2_3_27);
+        objectWrapperBuilder.setExposeFields(true);
+        DefaultObjectWrapper objectWrapper = objectWrapperBuilder.build();
+        configuration.setObjectWrapper(objectWrapper);
+        configuration.setLogTemplateExceptions(false);
+        configuration.setWrapUncheckedExceptions(true);
+        configuration.setFallbackOnNullLoopVariable(false);
+        return configuration;
+    }
+
+    @SuppressFBWarnings("TEMPLATE_INJECTION_FREEMARKER")
+    static void render(final Path templateFile, final Object templateData, final Path outputFile) {
+        try {
+            final Template template = CONFIGURATION.getTemplate(templateFile.toAbsolutePath().toString());
+            final Path outputFileParent = outputFile.getParent();
+            if (outputFileParent != null) {
+                Files.createDirectories(outputFileParent);
+            }
+            try (final BufferedWriter outputFileWriter = Files.newBufferedWriter(
+                    outputFile,
+                    CharsetUtils.CHARSET,
+                    StandardOpenOption.CREATE,
+                    StandardOpenOption.TRUNCATE_EXISTING)) {
+                template.process(templateData, outputFileWriter);
+            }
+        } catch (final Exception error) {
+            final String message = String.format(
+                    "failed rendering template `%s` to file `%s`",
+                    templateFile,
+                    outputFile);
+            throw new RuntimeException(message, error);
+        }
+    }
+
+}
diff --git a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/releaser/ChangelogReleaser.java b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/releaser/ChangelogReleaser.java
index 72b0e7b..c8bb858 100644
--- a/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/releaser/ChangelogReleaser.java
+++ b/log4j-changelog/src/main/java/org/apache/logging/log4j/tools/changelog/releaser/ChangelogReleaser.java
@@ -18,12 +18,10 @@ package org.apache.logging.log4j.tools.changelog.releaser;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.LocalDate;
 
-import org.apache.logging.log4j.tools.AsciiDocUtils;
 import org.apache.logging.log4j.tools.FileUtils;
 import org.apache.logging.log4j.tools.VersionUtils;
 import org.apache.logging.log4j.tools.changelog.ChangelogFiles;
@@ -48,23 +46,22 @@ public final class ChangelogReleaser {
         System.out.format("using `%s` for the release date%n", releaseDate);
 
         // Populate the changelog entry files in the release directory.
+        final Path unreleasedDirectory = ChangelogFiles.unreleasedDirectory(args.changelogDirectory, releaseVersionMajor);
         final Path releaseDirectory = releaseDirectory(args.changelogDirectory, args.releaseVersion);
-        populateChangelogEntryFiles(args.changelogDirectory, releaseVersionMajor, releaseDirectory);
+        populateChangelogEntryFiles(unreleasedDirectory, releaseDirectory);
 
         // Write the release information.
         populateReleaseXmlFiles(releaseDate, args.releaseVersion, releaseDirectory);
 
-        // Write the release introduction.
-        populateReleaseIntroAsciiDocFile(releaseDirectory);
+        // Write the release changelog template.
+        populateReleaseChangelogTemplateFile(unreleasedDirectory, releaseDirectory);
 
     }
 
     private static void populateChangelogEntryFiles(
-            final Path changelogDirectory,
-            int releaseVersionMajor,
+            final Path unreleasedDirectory,
             final Path releaseDirectory)
             throws IOException {
-        final Path unreleasedDirectory = ChangelogFiles.unreleasedDirectory(changelogDirectory, releaseVersionMajor);
         if (Files.exists(releaseDirectory)) {
             System.out.format(
                     "release directory `%s` already exists, only moving the changelog entry files from `%s`%n",
@@ -80,7 +77,7 @@ public final class ChangelogReleaser {
 
     private static void moveUnreleasedChangelogEntryFiles(final Path unreleasedDirectory, final Path releaseDirectory) {
         FileUtils
-                .findAdjacentFiles(unreleasedDirectory)
+                .findAdjacentFiles(unreleasedDirectory, true)
                 .forEach(unreleasedChangelogEntryFile -> {
                     final String fileName = unreleasedChangelogEntryFile.getFileName().toString();
                     final Path releasedChangelogEntryFile = releaseDirectory.resolve(fileName);
@@ -90,11 +87,7 @@ public final class ChangelogReleaser {
                     try {
                         Files.move(unreleasedChangelogEntryFile, releasedChangelogEntryFile);
                     } catch (final IOException error) {
-                        final String message = String.format(
-                                "failed to move `%s` to `%s`",
-                                unreleasedChangelogEntryFile,
-                                releasedChangelogEntryFile);
-                        throw new UncheckedIOException(message, error);
+                        throw new UncheckedIOException(error);
                     }
                 });
     }
@@ -126,13 +119,17 @@ public final class ChangelogReleaser {
         changelogRelease.writeToXmlFile(releaseXmlFile);
     }
 
-    private static void populateReleaseIntroAsciiDocFile(final Path releaseDirectory) throws IOException {
-        final Path introAsciiDocFile = ChangelogFiles.introAsciiDocFile(releaseDirectory);
-        if (Files.exists(introAsciiDocFile)) {
-            System.out.format("keeping the existing intro file: `%s`%n", introAsciiDocFile);
+    private static void populateReleaseChangelogTemplateFile(
+            final Path unreleasedDirectory,
+            final Path releaseDirectory)
+            throws IOException {
+        final Path targetFile = ChangelogFiles.releaseChangelogTemplateFile(releaseDirectory);
+        if (Files.exists(targetFile)) {
+            System.out.format("keeping the existing changelog template file: `%s`%n", targetFile);
         } else {
-            Files.write(introAsciiDocFile, AsciiDocUtils.LICENSE_COMMENT_BLOCK.getBytes(StandardCharsets.UTF_8));
-            System.out.format("created the intro file: `%s`%n", introAsciiDocFile);
+            final Path sourceFile = ChangelogFiles.releaseChangelogTemplateFile(unreleasedDirectory);
+            System.out.format("moving the changelog template file `%s` to `%s`%n", sourceFile, targetFile);
+            Files.move(sourceFile, targetFile);
         }
     }
 
diff --git a/log4j-tools-parent/pom.xml b/log4j-tools-parent/pom.xml
index 6885985..2c1c8ab 100644
--- a/log4j-tools-parent/pom.xml
+++ b/log4j-tools-parent/pom.xml
@@ -52,6 +52,7 @@
     <maven.site.deploy.skip>true</maven.site.deploy.skip>
 
     <!-- dependency versions -->
+    <freemarker.version>2.3.31</freemarker.version>
     <spotbugs.version>4.7.3</spotbugs.version>
 
     <!-- plugin versions -->
@@ -64,11 +65,19 @@
 
   <dependencyManagement>
     <dependencies>
+
       <dependency>
         <groupId>com.github.spotbugs</groupId>
         <artifactId>spotbugs-annotations</artifactId>
         <version>${spotbugs.version}</version>
       </dependency>
+
+      <dependency>
+        <groupId>org.freemarker</groupId>
+        <artifactId>freemarker</artifactId>
+        <version>${freemarker.version}</version>
+      </dependency>
+
     </dependencies>
   </dependencyManagement>