InjectBreadcrumbMojo.java
package network.ike.docs.plugin;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Inject navigation breadcrumbs and theme overrides into JaCoCo HTML reports.
*
* <p>Finds all HTML files in the target directory and:
* <ul>
* <li>Prepends a "back to project site" link in the breadcrumb div</li>
* <li>Injects a CSS link to {@code ike-theme.css} for visual alignment
* with the project's Maven site skin</li>
* <li>Writes {@code ike-theme.css} into the JaCoCo resources directory</li>
* </ul>
*
* <p>Usage:
* <pre>
* mvn ike:inject-breadcrumb -DtargetDir=target/site/jacoco
* </pre>
*/
@Mojo(name = "inject-breadcrumb",
defaultPhase = "verify")
public class InjectBreadcrumbMojo implements org.apache.maven.api.plugin.Mojo {
@org.apache.maven.api.di.Inject
private org.apache.maven.api.plugin.Log log;
/**
* Access the Maven logger.
*
* @return the logger
*/
protected org.apache.maven.api.plugin.Log getLog() { return log; }
/** Directory containing JaCoCo HTML reports. */
@Parameter(property = "targetDir",
defaultValue = "${project.build.directory}/site/jacoco")
File targetDir;
/** Relative URL for the breadcrumb link. */
@Parameter(property = "breadcrumb.link", defaultValue = "../index.html")
String link;
/** Display label for the breadcrumb link. */
@Parameter(property = "breadcrumb.label", defaultValue = "\u2190 Project Site")
String label;
/** Creates this goal instance. */
public InjectBreadcrumbMojo() {}
@Override
public void execute() throws MojoException {
if (!targetDir.isDirectory()) {
getLog().info("inject-breadcrumb: directory does not exist, "
+ "skipping — " + targetDir);
return;
}
// Write the theme CSS into the jacoco-resources directory
try {
writeThemeCss(targetDir.toPath());
} catch (IOException e) {
throw new MojoException(
"Failed to write theme CSS in " + targetDir, e);
}
int patched = 0;
try {
patched = processDirectory(targetDir.toPath(), link, label);
} catch (IOException e) {
throw new MojoException(
"Failed to inject breadcrumbs in " + targetDir, e);
}
if (patched > 0) {
getLog().info("inject-breadcrumb: patched " + patched
+ " HTML file(s) in " + targetDir);
} else {
getLog().info("inject-breadcrumb: no breadcrumb divs found in "
+ targetDir);
}
}
/**
* Write the IKE theme override CSS into the JaCoCo resources directory.
* Also writes into subdirectory resource dirs so source-file pages
* can find it.
*/
private void writeThemeCss(Path jacocoDir) throws IOException {
String css = generateThemeCss();
// Root jacoco-resources/
Path rootResources = jacocoDir.resolve("jacoco-resources");
if (Files.isDirectory(rootResources)) {
Files.writeString(rootResources.resolve("ike-theme.css"), css);
}
// Subdirectory jacoco-resources/ (package-level pages)
try (DirectoryStream<Path> stream = Files.newDirectoryStream(jacocoDir)) {
for (Path entry : stream) {
if (Files.isDirectory(entry)) {
Path subResources = entry.resolve("jacoco-resources");
if (Files.isDirectory(subResources)) {
Files.writeString(
subResources.resolve("ike-theme.css"), css);
}
}
}
}
}
/**
* Recursively process all HTML files in a directory tree.
*
* @return number of files that were modified
*/
private int processDirectory(Path dir, String breadcrumbLink,
String breadcrumbLabel) throws IOException {
int count = 0;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
for (Path entry : stream) {
if (Files.isDirectory(entry)) {
count += processDirectory(entry, breadcrumbLink,
breadcrumbLabel);
} else if (entry.toString().endsWith(".html")) {
String html = Files.readString(entry);
String patched = injectBreadcrumb(html, breadcrumbLink,
breadcrumbLabel);
patched = injectThemeCssLink(patched);
if (!html.equals(patched)) {
Files.writeString(entry, patched);
count++;
}
}
}
}
return count;
}
// ── Pure testable functions ──────────────────────────────────────
/**
* Inject a breadcrumb navigation link into JaCoCo's breadcrumb div.
*
* @param html the HTML content
* @param link relative URL for the breadcrumb link
* @param label display label for the link
* @return HTML with the breadcrumb injected, or unchanged if the
* breadcrumb div is not present
*/
public static String injectBreadcrumb(String html, String link,
String label) {
return html.replace(
"<div class=\"breadcrumb\" id=\"breadcrumb\">",
"<div class=\"breadcrumb\" id=\"breadcrumb\">"
+ "<a href=\"" + link
+ "\" style=\"font-weight:bold;margin-right:8px\">"
+ label + "</a> | ");
}
/**
* Inject a CSS link to the IKE theme override after the existing
* report.css link.
*
* @param html the HTML content
* @return HTML with the theme CSS link injected
*/
public static String injectThemeCssLink(String html) {
return html.replace(
"report.css\" type=\"text/css\"/>",
"report.css\" type=\"text/css\"/>"
+ "<link rel=\"stylesheet\" "
+ "href=\"jacoco-resources/ike-theme.css\" "
+ "type=\"text/css\"/>");
}
/**
* Generate the IKE theme override CSS for JaCoCo reports.
*
* <p>Overrides JaCoCo's default styling to approximate the Sentry
* Maven Skin purple theme: dark header bar, consistent font stack,
* purple accent colors, and improved table readability.
*
* @return CSS content as a string
*/
public static String generateThemeCss() {
return """
/* IKE Theme Override for JaCoCo Reports */
/* Aligns JaCoCo's default styling with the Sentry Maven Skin */
body, td {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 14px;
color: #333;
margin: 0;
padding: 0;
background: #fafafa;
}
body {
padding: 20px 40px;
max-width: 1400px;
margin: 0 auto;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #2d2d2d;
margin: 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid #6f42c1;
}
a {
color: #6f42c1;
text-decoration: none;
}
a:hover {
color: #553098;
text-decoration: underline;
}
/* Breadcrumb bar */
.breadcrumb {
background: #343a40;
color: #fff;
padding: 10px 16px;
border: none;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
}
.breadcrumb a {
color: #c9b3f5;
}
.breadcrumb a:hover {
color: #fff;
}
.breadcrumb .info {
float: right;
}
.breadcrumb .info a {
color: #adb5bd;
margin-left: 12px;
}
.breadcrumb .info a:hover {
color: #fff;
}
/* Coverage table */
table.coverage {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 4px;
overflow: hidden;
}
table.coverage thead {
background: #6f42c1;
color: #fff;
}
table.coverage thead td {
padding: 8px 14px 8px 8px;
border-bottom: 2px solid #553098;
font-weight: 600;
font-size: 13px;
}
table.coverage thead td.bar {
border-left: 1px solid #8057d4;
}
table.coverage thead td.ctr1,
table.coverage thead td.ctr2 {
border-left: 1px solid #8057d4;
}
table.coverage tbody td {
padding: 6px 8px;
border-bottom: 1px solid #e9ecef;
}
table.coverage tbody tr:hover {
background: #f3effc !important;
}
table.coverage tbody td.bar {
border-left: 1px solid #f0f0f0;
}
table.coverage tbody td.ctr1,
table.coverage tbody td.ctr2 {
border-left: 1px solid #f0f0f0;
padding-right: 14px;
}
table.coverage tfoot td {
padding: 8px;
font-weight: 600;
background: #f8f9fa;
border-top: 2px solid #dee2e6;
}
table.coverage tfoot td.bar {
border-left: 1px solid #e9ecef;
}
table.coverage tfoot td.ctr1,
table.coverage tfoot td.ctr2 {
border-left: 1px solid #e9ecef;
padding-right: 14px;
}
/* Source code view */
pre.source {
border: 1px solid #dee2e6;
border-radius: 4px;
background: #fff;
font-family: "SF Mono", "Fira Code", "Fira Mono",
"Roboto Mono", monospace;
font-size: 13px;
}
pre.source li {
border-left: 1px solid #dee2e6;
padding-left: 4px;
}
/* Footer */
.footer {
margin-top: 24px;
border-top: 1px solid #dee2e6;
padding-top: 8px;
font-size: 12px;
color: #6c757d;
}
.footer a {
color: #6c757d;
}
""";
}
}