View Javadoc
1   package com.guinetik.terminaljavadocs.plugin;
2   
3   import java.io.File;
4   import java.io.IOException;
5   import java.io.InputStream;
6   import java.nio.charset.StandardCharsets;
7   import java.nio.file.Files;
8   import java.nio.file.Path;
9   import java.nio.file.Paths;
10  import java.util.ArrayList;
11  import java.util.List;
12  import org.apache.maven.execution.MavenSession;
13  import org.apache.maven.plugin.AbstractMojo;
14  import org.apache.maven.plugin.MojoExecutionException;
15  import org.apache.maven.plugins.annotations.Mojo;
16  import org.apache.maven.plugins.annotations.Parameter;
17  import org.apache.maven.project.MavenProject;
18  
19  /**
20   * Generates landing pages for code coverage and source xref reports by scanning
21   * reactor modules for actual generated reports.
22   */
23  @Mojo(
24      name = "generate-landing-pages",
25      defaultPhase = org.apache.maven.plugins.annotations.LifecyclePhase.POST_SITE,
26      aggregator = true
27  )
28  public class GenerateLandingPagesMojo extends AbstractMojo {
29  
30      /**
31       * The current Maven session, providing access to reactor projects.
32       */
33      @Parameter(defaultValue = "${session}", readonly = true, required = true)
34      private MavenSession session;
35  
36      /**
37       * The current Maven project being built.
38       */
39      @Parameter(defaultValue = "${project}", readonly = true, required = true)
40      private MavenProject project;
41  
42      /**
43       * The project name to display in landing page headers.
44       * Defaults to the project's name from pom.xml.
45       */
46      @Parameter(
47          property = "terminaljavadocs.project.name",
48          defaultValue = "${project.name}"
49      )
50      private String projectName;
51  
52      /**
53       * Skip landing page generation when set to {@code true}.
54       * Can be set via {@code -Dterminaljavadocs.skip=true}.
55       */
56      @Parameter(property = "terminaljavadocs.skip", defaultValue = "false")
57      private boolean skip;
58  
59      /**
60       * The project's build output directory (typically {@code target/}).
61       */
62      @Parameter(defaultValue = "${project.build.directory}", readonly = true)
63      private File buildDirectory;
64  
65      /**
66       * Executes the landing page generation goal.
67       *
68       * <p>
69       * This method:
70       * <ol>
71       * <li>Skips execution if {@code skip=true} or project is not a POM</li>
72       * <li>Scans all reactor projects for JaCoCo and JXR reports</li>
73       * <li>Generates {@code coverage.html} if coverage reports exist</li>
74       * <li>Generates {@code source-xref.html} if xref reports exist</li>
75       * </ol>
76       *
77       * <p>
78       * Output is written to {@code target/staging} if it exists, otherwise {@code target/site}.
79       *
80       * @throws MojoExecutionException if template loading or file writing fails
81       */
82      @Override
83      public void execute() throws MojoExecutionException {
84          if (skip) {
85              getLog().info("Skipping landing page generation");
86              return;
87          }
88  
89          // Skip if this is not an aggregator build (only run on parent POM)
90          if (!"pom".equals(project.getPackaging())) {
91              getLog().debug(
92                  "Skipping landing page generation: not a POM project"
93              );
94              return;
95          }
96  
97          try {
98              List<ModuleReport> coverageModules = new ArrayList<>();
99              List<ModuleReport> xrefModules = new ArrayList<>();
100 
101             // Scan all reactor projects for reports
102             List<MavenProject> projects = session.getProjects();
103             getLog().info(
104                 "Scanning " + projects.size() + " reactor projects for reports"
105             );
106             for (MavenProject reactorProject : projects) {
107                 String artifactId = reactorProject.getArtifactId();
108                 String description = reactorProject.getDescription() != null
109                     ? reactorProject.getDescription()
110                     : "";
111                 String buildDir = reactorProject.getBuild().getDirectory();
112 
113                 // Check for coverage reports - try staging first, then site
114                 File jacocoIndex = new File(buildDir, "staging/jacoco/index.html");
115                 if (!jacocoIndex.exists()) {
116                     jacocoIndex = new File(buildDir, "site/jacoco/index.html");
117                 }
118                 getLog().debug(
119                     "Checking for coverage in " + artifactId + " at: " + jacocoIndex.getAbsolutePath()
120                 );
121                 if (jacocoIndex.exists()) {
122                     coverageModules.add(
123                         new ModuleReport(artifactId, description, artifactId)
124                     );
125                     getLog().info("Found coverage report in: " + artifactId);
126                 } else {
127                     getLog().debug(
128                         "No coverage report found for " + artifactId
129                     );
130                 }
131 
132                 // Check for xref reports - try staging first, then site
133                 // Use overview-summary.html (no-frames version) instead of index.html (frameset)
134                 File xrefIndex = new File(buildDir, "staging/xref/overview-summary.html");
135                 if (!xrefIndex.exists()) {
136                     xrefIndex = new File(buildDir, "site/xref/overview-summary.html");
137                 }
138                 getLog().debug(
139                     "Checking for xref in " + artifactId + " at: " + xrefIndex.getAbsolutePath()
140                 );
141                 if (xrefIndex.exists()) {
142                     xrefModules.add(
143                         new ModuleReport(artifactId, description, artifactId)
144                     );
145                     getLog().info("Found xref report in: " + artifactId);
146                 } else {
147                     getLog().debug(
148                         "No xref report found for " + artifactId
149                     );
150                 }
151             }
152 
153             // Determine the output directory - prefer staging if it exists, otherwise use site
154             File outputDir = new File(buildDirectory, "staging");
155             if (!outputDir.exists()) {
156                 outputDir = new File(buildDirectory, "site");
157             }
158             outputDir.mkdirs();
159 
160             // Generate coverage page if there are modules with coverage reports
161             if (!coverageModules.isEmpty()) {
162                 String coverageHtml = generateCoveragePage(
163                     coverageModules,
164                     projectName
165                 );
166                 Path coveragePath = Paths.get(
167                     outputDir.getAbsolutePath(),
168                     "coverage.html"
169                 );
170                 Files.write(
171                     coveragePath,
172                     coverageHtml.getBytes(StandardCharsets.UTF_8)
173                 );
174                 getLog().info(
175                     "Generated coverage landing page: " + coveragePath
176                 );
177             }
178 
179             // Generate xref page if there are modules with xref reports
180             if (!xrefModules.isEmpty()) {
181                 String xrefHtml = generateXrefPage(xrefModules, projectName);
182                 Path xrefPath = Paths.get(
183                     outputDir.getAbsolutePath(),
184                     "source-xref.html"
185                 );
186                 Files.write(
187                     xrefPath,
188                     xrefHtml.getBytes(StandardCharsets.UTF_8)
189                 );
190                 getLog().info("Generated xref landing page: " + xrefPath);
191             }
192 
193             if (coverageModules.isEmpty() && xrefModules.isEmpty()) {
194                 getLog().info("No modules with coverage or xref reports found");
195             }
196         } catch (IOException e) {
197             throw new MojoExecutionException(
198                 "Failed to generate landing pages",
199                 e
200             );
201         }
202     }
203 
204     /**
205      * Generates the coverage landing page HTML from the template.
206      *
207      * <p>
208      * Loads {@code templates/coverage-page.html} and replaces placeholders:
209      * <ul>
210      * <li>{@code {{project.name}}} - the project name</li>
211      * <li>{@code {{module-rows}}} - table rows for each module with coverage</li>
212      * </ul>
213      *
214      * @param modules     list of modules with coverage reports
215      * @param projectName the project name to display in the page header
216      * @return the rendered HTML content
217      * @throws IOException if the template cannot be loaded
218      */
219     private String generateCoveragePage(
220         List<ModuleReport> modules,
221         String projectName
222     ) throws IOException {
223         String template = loadTemplate("templates/coverage-page.html");
224 
225         // Generate module rows
226         StringBuilder rows = new StringBuilder();
227         for (ModuleReport module : modules) {
228             String row =
229                 "                <tr>\n" +
230                 "                    <td>" +
231                 escapeHtml(module.getArtifactId()) +
232                 "</td>\n" +
233                 "                    <td>" +
234                 escapeHtml(module.getDescription()) +
235                 "</td>\n" +
236                 "                    <td><a href=\"./" +
237                 escapeHtml(module.getArtifactId()) +
238                 "/jacoco/index.html\">View Coverage →</a></td>\n" +
239                 "                </tr>\n";
240             rows.append(row);
241         }
242 
243         // Replace placeholders
244         String result = template
245             .replace("{{project.name}}", escapeHtml(projectName))
246             .replace("{{module-rows}}", rows.toString());
247 
248         return result;
249     }
250 
251     /**
252      * Generates the source cross-reference landing page HTML from the template.
253      *
254      * <p>
255      * Loads {@code templates/xref-page.html} and replaces placeholders:
256      * <ul>
257      * <li>{@code {{project.name}}} - the project name</li>
258      * <li>{@code {{module-rows}}} - table rows for each module with xref reports</li>
259      * </ul>
260      *
261      * @param modules     list of modules with xref reports
262      * @param projectName the project name to display in the page header
263      * @return the rendered HTML content
264      * @throws IOException if the template cannot be loaded
265      */
266     private String generateXrefPage(
267         List<ModuleReport> modules,
268         String projectName
269     ) throws IOException {
270         String template = loadTemplate("templates/xref-page.html");
271 
272         // Generate module rows
273         StringBuilder rows = new StringBuilder();
274         for (ModuleReport module : modules) {
275             String row =
276                 "                <tr>\n" +
277                 "                    <td>" +
278                 escapeHtml(module.getArtifactId()) +
279                 "</td>\n" +
280                 "                    <td>" +
281                 escapeHtml(module.getDescription()) +
282                 "</td>\n" +
283                 "                    <td><a href=\"./" +
284                 escapeHtml(module.getArtifactId()) +
285                 "/xref/overview-summary.html\">Browse Source →</a></td>\n" +
286                 "                </tr>\n";
287             rows.append(row);
288         }
289 
290         // Replace placeholders
291         String result = template
292             .replace("{{project.name}}", escapeHtml(projectName))
293             .replace("{{module-rows}}", rows.toString());
294 
295         return result;
296     }
297 
298     /**
299      * Loads an HTML template from the classpath or filesystem.
300      *
301      * <p>
302      * Attempts to load the template in the following order:
303      * <ol>
304      * <li>Thread context classloader (packaged JAR)</li>
305      * <li>Class classloader (packaged JAR)</li>
306      * <li>Direct resource stream with leading slash</li>
307      * <li>Filesystem at {@code src/main/resources/} (development mode)</li>
308      * </ol>
309      *
310      * <p>
311      * The filesystem fallback enables template loading during development
312      * when the JAR hasn't been built yet.
313      *
314      * @param resourcePath the resource path relative to the classpath root
315      *                     (e.g., "templates/coverage-page.html")
316      * @return the template content as a UTF-8 string
317      * @throws IOException if the template cannot be found in any location
318      */
319     private String loadTemplate(String resourcePath) throws IOException {
320         // Try 1: Load from classloader (for packaged plugin JAR) - most reliable
321         InputStream is = null;
322 
323         // Try thread context classloader
324         is = Thread.currentThread()
325             .getContextClassLoader()
326             .getResourceAsStream(resourcePath);
327 
328         // Try class classloader
329         if (is == null) {
330             is =
331                 GenerateLandingPagesMojo.class.getClassLoader().getResourceAsStream(
332                     resourcePath
333                 );
334         }
335 
336         // Try Class.getResourceAsStream with leading slash
337         if (is == null) {
338             is = GenerateLandingPagesMojo.class.getResourceAsStream(
339                 "/" + resourcePath
340             );
341         }
342 
343         if (is != null) {
344             return new String(is.readAllBytes(), StandardCharsets.UTF_8);
345         }
346 
347         // Try 2: Load from plugin module filesystem (for development/build time)
348         // This allows the template to be loaded even if the JAR isn't fully built yet
349         try {
350             // Look for the template in the plugin module's source directory
351             // by scanning common Maven plugin locations relative to the class file
352             String classPath =
353                 GenerateLandingPagesMojo.class.getProtectionDomain()
354                     .getCodeSource()
355                     .getLocation()
356                     .getPath();
357 
358             // If running from target/classes, look in src/main/resources
359             if (classPath.contains("target" + File.separator + "classes")) {
360                 Path targetClasses = Paths.get(classPath);
361                 Path pluginModule = targetClasses.getParent().getParent(); // Go up from target/classes to plugin module
362                 Path templatePath = pluginModule.resolve(
363                     "src/main/resources/" + resourcePath
364                 );
365 
366                 if (Files.exists(templatePath)) {
367                     getLog().debug(
368                         "Loading template from filesystem: " + templatePath
369                     );
370                     return new String(
371                         Files.readAllBytes(templatePath),
372                         StandardCharsets.UTF_8
373                     );
374                 }
375             }
376         } catch (Exception e) {
377             getLog().debug(
378                 "Could not load template from filesystem: " + e.getMessage()
379             );
380         }
381 
382         throw new IOException(
383             "Template not found: " +
384                 resourcePath +
385                 ". Make sure templates are in src/main/resources/ or packaged in the plugin JAR."
386         );
387     }
388 
389     /**
390      * Escapes HTML special characters to prevent XSS vulnerabilities.
391      *
392      * <p>
393      * Converts the following characters:
394      * <ul>
395      * <li>{@code &} → {@code &amp;}</li>
396      * <li>{@code <} → {@code &lt;}</li>
397      * <li>{@code >} → {@code &gt;}</li>
398      * <li>{@code "} → {@code &quot;}</li>
399      * <li>{@code '} → {@code &#39;}</li>
400      * </ul>
401      *
402      * @param text the text to escape, may be {@code null}
403      * @return the escaped text, or empty string if input is {@code null}
404      */
405     private String escapeHtml(String text) {
406         if (text == null) {
407             return "";
408         }
409         return text
410             .replace("&", "&amp;")
411             .replace("<", "&lt;")
412             .replace(">", "&gt;")
413             .replace("\"", "&quot;")
414             .replace("'", "&#39;");
415     }
416 }