InjectSiteStylesMojo.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.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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;
/**
* Injects Terminal Javadocs styling (CSS + JS) into all HTML files
* generated by the Maven site.
*
* <p>
* This Mojo runs after site generation and:
* <ul>
* <li>Copies page-type-specific CSS files to the site directory</li>
* <li>Recursively scans all HTML files in the site directory</li>
* <li>Detects page type (coverage, jxr, javadoc, site)</li>
* <li>Injects the appropriate CSS and JS for each page type</li>
* <li>Supports nested sites (mono-repo style)</li>
* </ul>
*
* <p>
* The CSS/JS files are built from the css-zen-garden design system
* using npm and output to this plugin's resources.
*/
@Mojo(name = "inject-styles", defaultPhase = org.apache.maven.plugins.annotations.LifecyclePhase.POST_SITE)
public class InjectSiteStylesMojo extends AbstractMojo {
/**
* Page types with their corresponding CSS files.
*
* <p>
* Each page type maps to a specific minified CSS file that provides
* Terminal Javadocs styling for that category of generated documentation.
*/
public enum PageType {
/** Landing pages (coverage.html, source-xref.html). */
LANDING("landing", "terminaljavadocs-landing.min.css"),
/** JaCoCo coverage report pages. */
COVERAGE("coverage", "terminaljavadocs-coverage.min.css"),
/** JXR source cross-reference pages. */
JXR("jxr", "terminaljavadocs-jxr.min.css"),
/** Javadoc API documentation pages. */
JAVADOC("javadoc", "terminaljavadocs-javadoc.min.css"),
/** General Maven site pages. */
SITE("site", "terminaljavadocs-site.min.css");
/** The short name used in injection markers. */
private final String name;
/** The minified CSS filename for this page type. */
private final String cssFile;
/**
* Creates a page type with its associated CSS file.
*
* @param name the short name for logging and markers
* @param cssFile the minified CSS filename
*/
PageType(String name, String cssFile) {
this.name = name;
this.cssFile = cssFile;
}
/**
* Returns the short name of this page type.
*
* @return the name used in injection markers (e.g., "javadoc")
*/
public String getName() {
return name;
}
/**
* Returns the CSS filename for this page type.
*
* @return the minified CSS filename (e.g., "terminaljavadocs-javadoc.min.css")
*/
public String getCssFile() {
return cssFile;
}
}
/** Resource path prefix for styles within the plugin JAR. */
private static final String STYLES_RESOURCE_PATH = "styles/";
/** Filename of the bundled JavaScript file. */
private static final String JS_FILE = "terminaljavadocs.min.js";
/** Resource path prefix for themed JaCoCo images within the plugin JAR. */
private static final String JACOCO_RESOURCES_PATH = "jacoco-resources/";
/**
* JaCoCo resource files to copy (themed coverage bar images).
* These replace JaCoCo's default images with Terminal Javadocs styled versions.
*/
private static final String[] JACOCO_RESOURCE_FILES = {
"branchfc.gif", "branchnc.gif", "branchpc.gif",
"greenbar.gif", "redbar.gif",
"down.gif", "up.gif", "sort.gif"
};
/**
* HTML comment marker to detect already-injected pages.
* Prevents duplicate style injection on re-runs.
*/
private static final String INJECTION_MARKER = "<!-- terminal-javadocs-injected";
/**
* 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;
/**
* Skip style injection 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;
/**
* Directory name where styles will be copied within the site.
*/
@Parameter(property = "terminaljavadocs.stylesDir", defaultValue = "terminal-styles")
private String stylesDir;
/**
* Whether to process nested module sites.
*/
@Parameter(property = "terminaljavadocs.processNestedSites", defaultValue = "true")
private boolean processNestedSites;
/**
* Project name for branding in the navigation header.
* Replaces the %%PROJECT_NAME%% token in JavaScript.
*/
@Parameter(property = "terminaljavadocs.project.name", defaultValue = "${project.name}")
private String projectName;
/**
* Project logo URL for branding in the navigation header.
* Replaces the %%PROJECT_LOGO%% token in JavaScript.
* Can be an absolute URL or a path relative to the site root.
*/
@Parameter(property = "terminaljavadocs.project.logo", defaultValue = "")
private String projectLogo;
/** Counter for total HTML files processed. */
private int processedFiles = 0;
/** Counter for landing pages processed. */
private int landingFiles = 0;
/** Counter for coverage report pages processed. */
private int coverageFiles = 0;
/** Counter for JXR source pages processed. */
private int jxrFiles = 0;
/** Counter for Javadoc pages processed. */
private int javadocFiles = 0;
/** Counter for general site pages processed. */
private int siteFiles = 0;
/**
* Executes the style injection goal.
*
* <p>
* This method:
* <ol>
* <li>Locates the site output directory (staging or site)</li>
* <li>Copies CSS and JS resources to the styles directory</li>
* <li>Replaces JaCoCo's default images with themed versions</li>
* <li>Recursively processes all HTML files, injecting appropriate styles</li>
* <li>Processes nested module sites if enabled</li>
* </ol>
*
* @throws MojoExecutionException if file operations fail
*/
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping style injection");
return;
}
try {
// Determine the site directory - prefer staging if it exists
File siteDir = new File(buildDirectory, "staging");
if (!siteDir.exists()) {
siteDir = new File(buildDirectory, "site");
}
if (!siteDir.exists()) {
getLog().info("No site directory found, skipping style injection");
return;
}
getLog().info("Injecting Terminal Javadocs styles into site: " + siteDir.getAbsolutePath());
// Copy style resources to the site directory
File stylesTargetDir = new File(siteDir, stylesDir);
copyStyleResources(stylesTargetDir);
// Replace JaCoCo's default resources with themed versions
themeJacocoResources(siteDir);
// Process the main site
processHtmlFiles(siteDir, siteDir);
// Process nested module sites if enabled
if (processNestedSites) {
List<MavenProject> projects = session.getProjects();
// First, process staged module subdirectories (for site:stage)
// When using site:stage, modules are aggregated as subdirectories within
// target/staging
if (siteDir.getName().equals("staging")) {
for (MavenProject reactorProject : projects) {
if (reactorProject.equals(project)) {
continue; // Skip parent
}
String artifactId = reactorProject.getArtifactId();
File moduleStagedDir = new File(siteDir, artifactId);
if (moduleStagedDir.exists() && moduleStagedDir.isDirectory()) {
// Copy styles to staged module subdirectory
File moduleStylesDir = new File(moduleStagedDir, stylesDir);
copyStyleResources(moduleStylesDir);
// Replace JaCoCo resources in this module
themeJacocoResources(moduleStagedDir);
getLog().info("Processing staged module site: " + artifactId);
processHtmlFiles(moduleStagedDir, moduleStagedDir);
}
}
}
// Fallback: process individual module site directories (for mvn site without
// staging)
for (MavenProject reactorProject : projects) {
if (reactorProject.equals(project)) {
continue; // Skip parent, already processed
}
String buildDir = reactorProject.getBuild().getDirectory();
// Check staging first, then site
File moduleSiteDir = new File(buildDir, "staging");
if (!moduleSiteDir.exists()) {
moduleSiteDir = new File(buildDir, "site");
}
if (moduleSiteDir.exists()) {
String artifactId = reactorProject.getArtifactId();
// Copy styles to module site
File moduleStylesDir = new File(moduleSiteDir, stylesDir);
copyStyleResources(moduleStylesDir);
// Replace JaCoCo resources in this module
themeJacocoResources(moduleSiteDir);
getLog().info("Processing individual module site: " + artifactId);
processHtmlFiles(moduleSiteDir, moduleSiteDir);
}
}
}
// Log statistics
getLog().info("Style injection complete:");
getLog().info(" Total HTML files processed: " + processedFiles);
getLog().info(" Landing pages: " + landingFiles);
getLog().info(" Coverage pages: " + coverageFiles);
getLog().info(" JXR pages: " + jxrFiles);
getLog().info(" Javadoc pages: " + javadocFiles);
getLog().info(" Site pages: " + siteFiles);
} catch (IOException e) {
throw new MojoExecutionException("Failed to inject styles", e);
}
}
/**
* Copies all style resources (CSS and JS) from the plugin JAR to the target directory.
* JavaScript files are processed for token replacement.
*
* @param targetDir the directory to copy resources to (will be created if needed)
* @throws IOException if file copying fails
*/
private void copyStyleResources(File targetDir) throws IOException {
targetDir.mkdirs();
// Copy CSS for each page type
for (PageType pageType : PageType.values()) {
copyResource(
STYLES_RESOURCE_PATH + pageType.getCssFile(),
new File(targetDir, pageType.getCssFile()));
}
// Copy JS with token replacement
copyJsWithTokenReplacement(STYLES_RESOURCE_PATH + JS_FILE, new File(targetDir, JS_FILE));
}
/**
* Copies a JavaScript file with token replacement for project branding.
*
* <p>
* Replaces the following tokens:
* <ul>
* <li>{@code %%PROJECT_NAME%%} - replaced with {@link #projectName}</li>
* <li>{@code %%PROJECT_LOGO%%} - replaced with {@link #projectLogo}</li>
* </ul>
*
* @param resourcePath the classpath resource path to copy from
* @param targetFile the target file to copy to
* @throws IOException if file reading or writing fails
*/
private void copyJsWithTokenReplacement(String resourcePath, File targetFile) throws IOException {
try (InputStream is = getResourceStream(resourcePath)) {
if (is != null) {
// Java 8 compatible way to read all bytes from InputStream
java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
byte[] data = new byte[4096];
int bytesRead;
while ((bytesRead = is.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, bytesRead);
}
String content = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
// Replace tokens
content = content.replace("%%PROJECT_NAME%%", projectName != null ? projectName : "");
content = content.replace("%%PROJECT_LOGO%%", projectLogo != null ? projectLogo : "");
Files.write(targetFile.toPath(), content.getBytes(StandardCharsets.UTF_8));
getLog().debug("Copied JS with token replacement: " + resourcePath + " -> " + targetFile);
getLog().debug(" PROJECT_NAME: " + projectName);
getLog().debug(" PROJECT_LOGO: " + projectLogo);
} else {
getLog().warn("Resource not found: " + resourcePath +
". Run 'npm run build' in css-zen-garden to generate it.");
}
}
}
/**
* Copies a single resource file from the plugin JAR to the filesystem.
*
* @param resourcePath the classpath resource path to copy from
* @param targetFile the target file to copy to
* @throws IOException if file copying fails
*/
private void copyResource(String resourcePath, File targetFile) throws IOException {
try (InputStream is = getResourceStream(resourcePath)) {
if (is != null) {
Files.copy(is, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
getLog().debug("Copied resource: " + resourcePath + " -> " + targetFile);
} else {
getLog().warn("Resource not found: " + resourcePath +
". Run 'npm run build' in css-zen-garden to generate it.");
}
}
}
/**
* Copies themed JaCoCo resources (coverage bar images) to a jacoco-resources directory.
*
* @param jacocoResourcesDir the JaCoCo resources directory to update
* @throws IOException if file copying fails
*/
private void copyJacocoResources(File jacocoResourcesDir) throws IOException {
if (!jacocoResourcesDir.exists()) {
return;
}
for (String fileName : JACOCO_RESOURCE_FILES) {
copyResource(JACOCO_RESOURCES_PATH + fileName, new File(jacocoResourcesDir, fileName));
}
getLog().debug("Copied themed JaCoCo resources to: " + jacocoResourcesDir);
}
/**
* Finds and themes all JaCoCo resource directories in the given site directory.
* Recursively walks the directory tree looking for {@code jacoco-resources} folders.
*
* @param siteDir the site directory to search
* @throws IOException if directory traversal or file copying fails
*/
private void themeJacocoResources(File siteDir) throws IOException {
Files.walkFileTree(siteDir.toPath(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
if (dir.getFileName() != null && dir.getFileName().toString().equals("jacoco-resources")) {
copyJacocoResources(dir.toFile());
}
return FileVisitResult.CONTINUE;
}
});
}
/**
* Gets an input stream for a classpath resource, trying multiple classloaders.
*
* <p>
* Attempts loading in this order:
* <ol>
* <li>Thread context classloader</li>
* <li>Class classloader</li>
* <li>Direct resource stream with leading slash</li>
* </ol>
*
* @param resourcePath the resource path to load
* @return the input stream, or {@code null} if not found
*/
private InputStream getResourceStream(String resourcePath) {
InputStream is = Thread.currentThread()
.getContextClassLoader()
.getResourceAsStream(resourcePath);
if (is == null) {
is = InjectSiteStylesMojo.class.getClassLoader().getResourceAsStream(resourcePath);
}
if (is == null) {
is = InjectSiteStylesMojo.class.getResourceAsStream("/" + resourcePath);
}
return is;
}
/**
* Processes all HTML files in the given directory recursively.
*
* @param directory the directory to scan for HTML files
* @param siteRoot the root of the site (for relative path calculations)
* @throws IOException if file traversal or processing fails
*/
private void processHtmlFiles(File directory, File siteRoot) throws IOException {
Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (file.toString().endsWith(".html")) {
processHtmlFile(file.toFile(), siteRoot);
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
getLog().warn("Failed to visit file: " + file + " - " + exc.getMessage());
return FileVisitResult.CONTINUE;
}
});
}
/**
* Processes a single HTML file by detecting its type and injecting appropriate styles.
*
* @param htmlFile the HTML file to process
* @param siteRoot the root of the site (for relative path calculations)
* @throws IOException if file reading or writing fails
*/
private void processHtmlFile(File htmlFile, File siteRoot) throws IOException {
String content = new String(Files.readAllBytes(htmlFile.toPath()), StandardCharsets.UTF_8);
// Check if styles are already injected (avoid duplicate injection)
if (content.contains(INJECTION_MARKER)) {
getLog().debug("Skipping already injected file: " + htmlFile);
return;
}
// Detect page type
PageType pageType = detectPageType(htmlFile, content);
// Calculate relative path to styles directory
String relativePath = calculateRelativePath(htmlFile, siteRoot);
// Generate the style injection snippet
String styleSnippet = generateStyleSnippet(pageType, relativePath);
// Inject styles before </head>
String modifiedContent = injectStyles(content, styleSnippet);
if (modifiedContent != null) {
Files.write(htmlFile.toPath(), modifiedContent.getBytes(StandardCharsets.UTF_8));
processedFiles++;
// Update statistics
switch (pageType) {
case LANDING:
landingFiles++;
break;
case COVERAGE:
coverageFiles++;
break;
case JXR:
jxrFiles++;
break;
case JAVADOC:
javadocFiles++;
break;
case SITE:
siteFiles++;
break;
}
getLog().debug("Injected " + pageType.getName() + " styles into: " + htmlFile);
}
}
/**
* Detects the page type based on file path and content.
*
* <p>
* Detection priority:
* <ol>
* <li>Filename match (coverage.html, source-xref.html)</li>
* <li>Path-based detection (/jacoco/, /xref/, /apidocs/)</li>
* <li>Content-based detection (HTML markers)</li>
* <li>Default to SITE type</li>
* </ol>
*
* @param htmlFile the HTML file being processed
* @param content the file's content
* @return the detected page type
*/
private PageType detectPageType(File htmlFile, String content) {
String path = htmlFile.getAbsolutePath().replace('\\', '/').toLowerCase();
String fileName = htmlFile.getName().toLowerCase();
// Landing pages (generated by generate-landing-pages goal)
if (fileName.equals("coverage.html") || fileName.equals("source-xref.html")) {
return PageType.LANDING;
}
// Path-based detection (most reliable)
if (path.contains("/jacoco/") || path.contains("/coverage/")) {
return PageType.COVERAGE;
}
if (path.contains("/xref/") || path.contains("/xref-test/")) {
return PageType.JXR;
}
if (path.contains("/apidocs/") || path.contains("/testapidocs/") || path.contains("/javadoc/")) {
return PageType.JAVADOC;
}
// Content-based detection (fallback)
if (isLandingContent(content)) {
return PageType.LANDING;
}
if (isJacocoContent(content)) {
return PageType.COVERAGE;
}
if (isJxrContent(content)) {
return PageType.JXR;
}
if (isJavadocContent(content)) {
return PageType.JAVADOC;
}
// Default to site
return PageType.SITE;
}
/**
* Checks if content is from a landing page (coverage.html or source-xref.html).
*
* @param content the HTML content to check
* @return {@code true} if landing page markers are found
*/
private boolean isLandingContent(String content) {
return content.contains("class=\"terminal-header\"") ||
content.contains("class=\"terminal-brand\"") ||
content.contains("class=\"module-list\"");
}
/**
* Checks if content is from a JaCoCo coverage report.
*
* @param content the HTML content to check
* @return {@code true} if JaCoCo markers are found
*/
private boolean isJacocoContent(String content) {
return content.contains("jacoco") ||
content.contains("Coverage Report") ||
content.contains("class=\"el_package\"") ||
content.contains("class=\"el_class\"") ||
content.contains("class=\"ctr2\"");
}
/**
* Checks if content is from a JXR source cross-reference page.
*
* @param content the HTML content to check
* @return {@code true} if JXR markers are found
*/
private boolean isJxrContent(String content) {
return content.contains("jxr") ||
content.contains("Cross-Reference") ||
content.contains("class=\"jxr_") ||
content.contains("id=\"jxr_");
}
/**
* Checks if content is from Javadoc API documentation.
*
* @param content the HTML content to check
* @return {@code true} if Javadoc markers are found
*/
private boolean isJavadocContent(String content) {
return content.contains("Generated by javadoc") ||
content.contains("<!-- Generated by javadoc") ||
content.contains("class=\"summary-table\"") ||
content.contains("class=\"member-signature\"") ||
content.contains("class=\"description\"");
}
/**
* Calculates the relative path from an HTML file's directory to the site root.
*
* @param htmlFile the HTML file being processed
* @param siteRoot the root directory of the site
* @return the relative path with trailing slash (e.g., "../../../")
*/
private String calculateRelativePath(File htmlFile, File siteRoot) {
Path htmlPath = htmlFile.toPath().getParent();
Path rootPath = siteRoot.toPath();
try {
Path relativePath = htmlPath.relativize(rootPath);
String result = relativePath.toString().replace('\\', '/');
if (result.isEmpty()) {
return "./";
}
if (!result.endsWith("/")) {
result += "/";
}
return result;
} catch (IllegalArgumentException e) {
// Paths are on different roots, use absolute
return "./";
}
}
/**
* Generates the HTML snippet to inject for a given page type.
*
* <p>
* The snippet includes:
* <ul>
* <li>An HTML comment marker for detection</li>
* <li>A CSS link tag for the page-specific stylesheet</li>
* <li>A deferred script tag for the JavaScript bundle</li>
* </ul>
*
* @param pageType the type of page being processed
* @param relativePath the relative path to the site root
* @return the HTML snippet to inject
*/
private String generateStyleSnippet(PageType pageType, String relativePath) {
String stylesPath = relativePath + stylesDir + "/";
return "\n<!-- " + INJECTION_MARKER + " [" + pageType.getName() + "] -->\n" +
"<link rel=\"stylesheet\" href=\"" + stylesPath + pageType.getCssFile() + "\">\n" +
"<script src=\"" + stylesPath + JS_FILE + "\" defer></script>\n";
}
/**
* Injects the style snippet into HTML content before the closing head tag.
*
* <p>
* Injection strategy:
* <ol>
* <li>Try to inject before {@code </head>} (preferred)</li>
* <li>Fall back to after {@code <head>} if no closing tag</li>
* <li>Return {@code null} if no head section found</li>
* </ol>
*
* @param content the HTML content to modify
* @param styleSnippet the style snippet to inject
* @return the modified content, or {@code null} if injection failed
*/
private String injectStyles(String content, String styleSnippet) {
// Try to inject before </head>
Pattern headPattern = Pattern.compile("(</head>)", Pattern.CASE_INSENSITIVE);
Matcher matcher = headPattern.matcher(content);
if (matcher.find()) {
return content.substring(0, matcher.start()) +
styleSnippet +
content.substring(matcher.start());
}
// If no </head>, try after <head>
Pattern headOpenPattern = Pattern.compile("(<head[^>]*>)", Pattern.CASE_INSENSITIVE);
Matcher openMatcher = headOpenPattern.matcher(content);
if (openMatcher.find()) {
return content.substring(0, openMatcher.end()) +
styleSnippet +
content.substring(openMatcher.end());
}
// No head section found
getLog().warn("No <head> section found in HTML, cannot inject styles");
return null;
}
}