GenerateLandingPagesMojo.java
package com.guinetik.terminaljavadocs.plugin;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
/**
* Generates landing pages for code coverage and source xref reports by scanning
* reactor modules for actual generated reports.
*/
@Mojo(
name = "generate-landing-pages",
defaultPhase = org.apache.maven.plugins.annotations.LifecyclePhase.POST_SITE,
aggregator = true
)
public class GenerateLandingPagesMojo extends AbstractMojo {
/**
* The current Maven session, providing access to reactor projects.
*/
@Parameter(defaultValue = "${session}", readonly = true, required = true)
private MavenSession session;
/**
* The current Maven project being built.
*/
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
/**
* The project name to display in landing page headers.
* Defaults to the project's name from pom.xml.
*/
@Parameter(
property = "terminaljavadocs.project.name",
defaultValue = "${project.name}"
)
private String projectName;
/**
* Skip landing page generation when set to {@code true}.
* Can be set via {@code -Dterminaljavadocs.skip=true}.
*/
@Parameter(property = "terminaljavadocs.skip", defaultValue = "false")
private boolean skip;
/**
* The project's build output directory (typically {@code target/}).
*/
@Parameter(defaultValue = "${project.build.directory}", readonly = true)
private File buildDirectory;
/**
* Executes the landing page generation goal.
*
* <p>
* This method:
* <ol>
* <li>Skips execution if {@code skip=true} or project is not a POM</li>
* <li>Scans all reactor projects for JaCoCo and JXR reports</li>
* <li>Generates {@code coverage.html} if coverage reports exist</li>
* <li>Generates {@code source-xref.html} if xref reports exist</li>
* </ol>
*
* <p>
* Output is written to {@code target/staging} if it exists, otherwise {@code target/site}.
*
* @throws MojoExecutionException if template loading or file writing fails
*/
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping landing page generation");
return;
}
// Skip if this is not an aggregator build (only run on parent POM)
if (!"pom".equals(project.getPackaging())) {
getLog().debug(
"Skipping landing page generation: not a POM project"
);
return;
}
try {
List<ModuleReport> coverageModules = new ArrayList<>();
List<ModuleReport> xrefModules = new ArrayList<>();
// Scan all reactor projects for reports
List<MavenProject> projects = session.getProjects();
getLog().info(
"Scanning " + projects.size() + " reactor projects for reports"
);
for (MavenProject reactorProject : projects) {
String artifactId = reactorProject.getArtifactId();
String description = reactorProject.getDescription() != null
? reactorProject.getDescription()
: "";
String buildDir = reactorProject.getBuild().getDirectory();
// Check for coverage reports - try staging first, then site
File jacocoIndex = new File(buildDir, "staging/jacoco/index.html");
if (!jacocoIndex.exists()) {
jacocoIndex = new File(buildDir, "site/jacoco/index.html");
}
getLog().debug(
"Checking for coverage in " + artifactId + " at: " + jacocoIndex.getAbsolutePath()
);
if (jacocoIndex.exists()) {
coverageModules.add(
new ModuleReport(artifactId, description, artifactId)
);
getLog().info("Found coverage report in: " + artifactId);
} else {
getLog().debug(
"No coverage report found for " + artifactId
);
}
// Check for xref reports - try staging first, then site
// Use overview-summary.html (no-frames version) instead of index.html (frameset)
File xrefIndex = new File(buildDir, "staging/xref/overview-summary.html");
if (!xrefIndex.exists()) {
xrefIndex = new File(buildDir, "site/xref/overview-summary.html");
}
getLog().debug(
"Checking for xref in " + artifactId + " at: " + xrefIndex.getAbsolutePath()
);
if (xrefIndex.exists()) {
xrefModules.add(
new ModuleReport(artifactId, description, artifactId)
);
getLog().info("Found xref report in: " + artifactId);
} else {
getLog().debug(
"No xref report found for " + artifactId
);
}
}
// Determine the output directory - prefer staging if it exists, otherwise use site
File outputDir = new File(buildDirectory, "staging");
if (!outputDir.exists()) {
outputDir = new File(buildDirectory, "site");
}
outputDir.mkdirs();
// Generate coverage page if there are modules with coverage reports
if (!coverageModules.isEmpty()) {
String coverageHtml = generateCoveragePage(
coverageModules,
projectName
);
Path coveragePath = Paths.get(
outputDir.getAbsolutePath(),
"coverage.html"
);
Files.write(
coveragePath,
coverageHtml.getBytes(StandardCharsets.UTF_8)
);
getLog().info(
"Generated coverage landing page: " + coveragePath
);
}
// Generate xref page if there are modules with xref reports
if (!xrefModules.isEmpty()) {
String xrefHtml = generateXrefPage(xrefModules, projectName);
Path xrefPath = Paths.get(
outputDir.getAbsolutePath(),
"source-xref.html"
);
Files.write(
xrefPath,
xrefHtml.getBytes(StandardCharsets.UTF_8)
);
getLog().info("Generated xref landing page: " + xrefPath);
}
if (coverageModules.isEmpty() && xrefModules.isEmpty()) {
getLog().info("No modules with coverage or xref reports found");
}
} catch (IOException e) {
throw new MojoExecutionException(
"Failed to generate landing pages",
e
);
}
}
/**
* Generates the coverage landing page HTML from the template.
*
* <p>
* Loads {@code templates/coverage-page.html} and replaces placeholders:
* <ul>
* <li>{@code {{project.name}}} - the project name</li>
* <li>{@code {{module-rows}}} - table rows for each module with coverage</li>
* </ul>
*
* @param modules list of modules with coverage reports
* @param projectName the project name to display in the page header
* @return the rendered HTML content
* @throws IOException if the template cannot be loaded
*/
private String generateCoveragePage(
List<ModuleReport> modules,
String projectName
) throws IOException {
String template = loadTemplate("templates/coverage-page.html");
// Generate module rows
StringBuilder rows = new StringBuilder();
for (ModuleReport module : modules) {
String row =
" <tr>\n" +
" <td>" +
escapeHtml(module.getArtifactId()) +
"</td>\n" +
" <td>" +
escapeHtml(module.getDescription()) +
"</td>\n" +
" <td><a href=\"./" +
escapeHtml(module.getArtifactId()) +
"/jacoco/index.html\">View Coverage →</a></td>\n" +
" </tr>\n";
rows.append(row);
}
// Replace placeholders
String result = template
.replace("{{project.name}}", escapeHtml(projectName))
.replace("{{module-rows}}", rows.toString());
return result;
}
/**
* Generates the source cross-reference landing page HTML from the template.
*
* <p>
* Loads {@code templates/xref-page.html} and replaces placeholders:
* <ul>
* <li>{@code {{project.name}}} - the project name</li>
* <li>{@code {{module-rows}}} - table rows for each module with xref reports</li>
* </ul>
*
* @param modules list of modules with xref reports
* @param projectName the project name to display in the page header
* @return the rendered HTML content
* @throws IOException if the template cannot be loaded
*/
private String generateXrefPage(
List<ModuleReport> modules,
String projectName
) throws IOException {
String template = loadTemplate("templates/xref-page.html");
// Generate module rows
StringBuilder rows = new StringBuilder();
for (ModuleReport module : modules) {
String row =
" <tr>\n" +
" <td>" +
escapeHtml(module.getArtifactId()) +
"</td>\n" +
" <td>" +
escapeHtml(module.getDescription()) +
"</td>\n" +
" <td><a href=\"./" +
escapeHtml(module.getArtifactId()) +
"/xref/overview-summary.html\">Browse Source →</a></td>\n" +
" </tr>\n";
rows.append(row);
}
// Replace placeholders
String result = template
.replace("{{project.name}}", escapeHtml(projectName))
.replace("{{module-rows}}", rows.toString());
return result;
}
/**
* Loads an HTML template from the classpath or filesystem.
*
* <p>
* Attempts to load the template in the following order:
* <ol>
* <li>Thread context classloader (packaged JAR)</li>
* <li>Class classloader (packaged JAR)</li>
* <li>Direct resource stream with leading slash</li>
* <li>Filesystem at {@code src/main/resources/} (development mode)</li>
* </ol>
*
* <p>
* The filesystem fallback enables template loading during development
* when the JAR hasn't been built yet.
*
* @param resourcePath the resource path relative to the classpath root
* (e.g., "templates/coverage-page.html")
* @return the template content as a UTF-8 string
* @throws IOException if the template cannot be found in any location
*/
private String loadTemplate(String resourcePath) throws IOException {
// Try 1: Load from classloader (for packaged plugin JAR) - most reliable
InputStream is = null;
// Try thread context classloader
is = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resourcePath);
// Try class classloader
if (is == null) {
is =
GenerateLandingPagesMojo.class.getClassLoader().getResourceAsStream(
resourcePath
);
}
// Try Class.getResourceAsStream with leading slash
if (is == null) {
is = GenerateLandingPagesMojo.class.getResourceAsStream(
"/" + resourcePath
);
}
if (is != null) {
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
}
// Try 2: Load from plugin module filesystem (for development/build time)
// This allows the template to be loaded even if the JAR isn't fully built yet
try {
// Look for the template in the plugin module's source directory
// by scanning common Maven plugin locations relative to the class file
String classPath =
GenerateLandingPagesMojo.class.getProtectionDomain()
.getCodeSource()
.getLocation()
.getPath();
// If running from target/classes, look in src/main/resources
if (classPath.contains("target" + File.separator + "classes")) {
Path targetClasses = Paths.get(classPath);
Path pluginModule = targetClasses.getParent().getParent(); // Go up from target/classes to plugin module
Path templatePath = pluginModule.resolve(
"src/main/resources/" + resourcePath
);
if (Files.exists(templatePath)) {
getLog().debug(
"Loading template from filesystem: " + templatePath
);
return new String(
Files.readAllBytes(templatePath),
StandardCharsets.UTF_8
);
}
}
} catch (Exception e) {
getLog().debug(
"Could not load template from filesystem: " + e.getMessage()
);
}
throw new IOException(
"Template not found: " +
resourcePath +
". Make sure templates are in src/main/resources/ or packaged in the plugin JAR."
);
}
/**
* Escapes HTML special characters to prevent XSS vulnerabilities.
*
* <p>
* Converts the following characters:
* <ul>
* <li>{@code &} → {@code &}</li>
* <li>{@code <} → {@code <}</li>
* <li>{@code >} → {@code >}</li>
* <li>{@code "} → {@code "}</li>
* <li>{@code '} → {@code '}</li>
* </ul>
*
* @param text the text to escape, may be {@code null}
* @return the escaped text, or empty string if input is {@code null}
*/
private String escapeHtml(String text) {
if (text == null) {
return "";
}
return text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
}