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.FileVisitResult;
8   import java.nio.file.Files;
9   import java.nio.file.Path;
10  import java.nio.file.SimpleFileVisitor;
11  import java.nio.file.StandardCopyOption;
12  import java.nio.file.attribute.BasicFileAttributes;
13  import java.util.List;
14  import java.util.regex.Matcher;
15  import java.util.regex.Pattern;
16  import org.apache.maven.execution.MavenSession;
17  import org.apache.maven.plugin.AbstractMojo;
18  import org.apache.maven.plugin.MojoExecutionException;
19  import org.apache.maven.plugins.annotations.Mojo;
20  import org.apache.maven.plugins.annotations.Parameter;
21  import org.apache.maven.project.MavenProject;
22  
23  /**
24   * Injects Terminal Javadocs styling (CSS + JS) into all HTML files
25   * generated by the Maven site.
26   *
27   * <p>
28   * This Mojo runs after site generation and:
29   * <ul>
30   * <li>Copies page-type-specific CSS files to the site directory</li>
31   * <li>Recursively scans all HTML files in the site directory</li>
32   * <li>Detects page type (coverage, jxr, javadoc, site)</li>
33   * <li>Injects the appropriate CSS and JS for each page type</li>
34   * <li>Supports nested sites (mono-repo style)</li>
35   * </ul>
36   *
37   * <p>
38   * The CSS/JS files are built from the css-zen-garden design system
39   * using npm and output to this plugin's resources.
40   */
41  @Mojo(name = "inject-styles", defaultPhase = org.apache.maven.plugins.annotations.LifecyclePhase.POST_SITE)
42  public class InjectSiteStylesMojo extends AbstractMojo {
43  
44      /**
45       * Page types with their corresponding CSS files.
46       *
47       * <p>
48       * Each page type maps to a specific minified CSS file that provides
49       * Terminal Javadocs styling for that category of generated documentation.
50       */
51      public enum PageType {
52          /** Landing pages (coverage.html, source-xref.html). */
53          LANDING("landing", "terminaljavadocs-landing.min.css"),
54          /** JaCoCo coverage report pages. */
55          COVERAGE("coverage", "terminaljavadocs-coverage.min.css"),
56          /** JXR source cross-reference pages. */
57          JXR("jxr", "terminaljavadocs-jxr.min.css"),
58          /** Javadoc API documentation pages. */
59          JAVADOC("javadoc", "terminaljavadocs-javadoc.min.css"),
60          /** General Maven site pages. */
61          SITE("site", "terminaljavadocs-site.min.css");
62  
63          /** The short name used in injection markers. */
64          private final String name;
65  
66          /** The minified CSS filename for this page type. */
67          private final String cssFile;
68  
69          /**
70           * Creates a page type with its associated CSS file.
71           *
72           * @param name    the short name for logging and markers
73           * @param cssFile the minified CSS filename
74           */
75          PageType(String name, String cssFile) {
76              this.name = name;
77              this.cssFile = cssFile;
78          }
79  
80          /**
81           * Returns the short name of this page type.
82           *
83           * @return the name used in injection markers (e.g., "javadoc")
84           */
85          public String getName() {
86              return name;
87          }
88  
89          /**
90           * Returns the CSS filename for this page type.
91           *
92           * @return the minified CSS filename (e.g., "terminaljavadocs-javadoc.min.css")
93           */
94          public String getCssFile() {
95              return cssFile;
96          }
97      }
98  
99      /** Resource path prefix for styles within the plugin JAR. */
100     private static final String STYLES_RESOURCE_PATH = "styles/";
101 
102     /** Filename of the bundled JavaScript file. */
103     private static final String JS_FILE = "terminaljavadocs.min.js";
104 
105     /** Resource path prefix for themed JaCoCo images within the plugin JAR. */
106     private static final String JACOCO_RESOURCES_PATH = "jacoco-resources/";
107 
108     /**
109      * JaCoCo resource files to copy (themed coverage bar images).
110      * These replace JaCoCo's default images with Terminal Javadocs styled versions.
111      */
112     private static final String[] JACOCO_RESOURCE_FILES = {
113         "branchfc.gif", "branchnc.gif", "branchpc.gif",
114         "greenbar.gif", "redbar.gif",
115         "down.gif", "up.gif", "sort.gif"
116     };
117 
118     /**
119      * HTML comment marker to detect already-injected pages.
120      * Prevents duplicate style injection on re-runs.
121      */
122     private static final String INJECTION_MARKER = "<!-- terminal-javadocs-injected";
123 
124     /**
125      * The current Maven session, providing access to reactor projects.
126      */
127     @Parameter(defaultValue = "${session}", readonly = true, required = true)
128     private MavenSession session;
129 
130     /**
131      * The current Maven project being built.
132      */
133     @Parameter(defaultValue = "${project}", readonly = true, required = true)
134     private MavenProject project;
135 
136     /**
137      * Skip style injection when set to {@code true}.
138      * Can be set via {@code -Dterminaljavadocs.skip=true}.
139      */
140     @Parameter(property = "terminaljavadocs.skip", defaultValue = "false")
141     private boolean skip;
142 
143     /**
144      * The project's build output directory (typically {@code target/}).
145      */
146     @Parameter(defaultValue = "${project.build.directory}", readonly = true)
147     private File buildDirectory;
148 
149     /**
150      * Directory name where styles will be copied within the site.
151      */
152     @Parameter(property = "terminaljavadocs.stylesDir", defaultValue = "terminal-styles")
153     private String stylesDir;
154 
155     /**
156      * Whether to process nested module sites.
157      */
158     @Parameter(property = "terminaljavadocs.processNestedSites", defaultValue = "true")
159     private boolean processNestedSites;
160 
161     /**
162      * Project name for branding in the navigation header.
163      * Replaces the %%PROJECT_NAME%% token in JavaScript.
164      */
165     @Parameter(property = "terminaljavadocs.project.name", defaultValue = "${project.name}")
166     private String projectName;
167 
168     /**
169      * Project logo URL for branding in the navigation header.
170      * Replaces the %%PROJECT_LOGO%% token in JavaScript.
171      * Can be an absolute URL or a path relative to the site root.
172      */
173     @Parameter(property = "terminaljavadocs.project.logo", defaultValue = "")
174     private String projectLogo;
175 
176     /** Counter for total HTML files processed. */
177     private int processedFiles = 0;
178 
179     /** Counter for landing pages processed. */
180     private int landingFiles = 0;
181 
182     /** Counter for coverage report pages processed. */
183     private int coverageFiles = 0;
184 
185     /** Counter for JXR source pages processed. */
186     private int jxrFiles = 0;
187 
188     /** Counter for Javadoc pages processed. */
189     private int javadocFiles = 0;
190 
191     /** Counter for general site pages processed. */
192     private int siteFiles = 0;
193 
194     /**
195      * Executes the style injection goal.
196      *
197      * <p>
198      * This method:
199      * <ol>
200      * <li>Locates the site output directory (staging or site)</li>
201      * <li>Copies CSS and JS resources to the styles directory</li>
202      * <li>Replaces JaCoCo's default images with themed versions</li>
203      * <li>Recursively processes all HTML files, injecting appropriate styles</li>
204      * <li>Processes nested module sites if enabled</li>
205      * </ol>
206      *
207      * @throws MojoExecutionException if file operations fail
208      */
209     @Override
210     public void execute() throws MojoExecutionException {
211         if (skip) {
212             getLog().info("Skipping style injection");
213             return;
214         }
215 
216         try {
217             // Determine the site directory - prefer staging if it exists
218             File siteDir = new File(buildDirectory, "staging");
219             if (!siteDir.exists()) {
220                 siteDir = new File(buildDirectory, "site");
221             }
222 
223             if (!siteDir.exists()) {
224                 getLog().info("No site directory found, skipping style injection");
225                 return;
226             }
227 
228             getLog().info("Injecting Terminal Javadocs styles into site: " + siteDir.getAbsolutePath());
229 
230             // Copy style resources to the site directory
231             File stylesTargetDir = new File(siteDir, stylesDir);
232             copyStyleResources(stylesTargetDir);
233 
234             // Replace JaCoCo's default resources with themed versions
235             themeJacocoResources(siteDir);
236 
237             // Process the main site
238             processHtmlFiles(siteDir, siteDir);
239 
240             // Process nested module sites if enabled
241             if (processNestedSites) {
242                 List<MavenProject> projects = session.getProjects();
243 
244                 // First, process staged module subdirectories (for site:stage)
245                 // When using site:stage, modules are aggregated as subdirectories within
246                 // target/staging
247                 if (siteDir.getName().equals("staging")) {
248                     for (MavenProject reactorProject : projects) {
249                         if (reactorProject.equals(project)) {
250                             continue; // Skip parent
251                         }
252 
253                         String artifactId = reactorProject.getArtifactId();
254                         File moduleStagedDir = new File(siteDir, artifactId);
255 
256                         if (moduleStagedDir.exists() && moduleStagedDir.isDirectory()) {
257                             // Copy styles to staged module subdirectory
258                             File moduleStylesDir = new File(moduleStagedDir, stylesDir);
259                             copyStyleResources(moduleStylesDir);
260 
261                             // Replace JaCoCo resources in this module
262                             themeJacocoResources(moduleStagedDir);
263 
264                             getLog().info("Processing staged module site: " + artifactId);
265                             processHtmlFiles(moduleStagedDir, moduleStagedDir);
266                         }
267                     }
268                 }
269 
270                 // Fallback: process individual module site directories (for mvn site without
271                 // staging)
272                 for (MavenProject reactorProject : projects) {
273                     if (reactorProject.equals(project)) {
274                         continue; // Skip parent, already processed
275                     }
276 
277                     String buildDir = reactorProject.getBuild().getDirectory();
278 
279                     // Check staging first, then site
280                     File moduleSiteDir = new File(buildDir, "staging");
281                     if (!moduleSiteDir.exists()) {
282                         moduleSiteDir = new File(buildDir, "site");
283                     }
284 
285                     if (moduleSiteDir.exists()) {
286                         String artifactId = reactorProject.getArtifactId();
287 
288                         // Copy styles to module site
289                         File moduleStylesDir = new File(moduleSiteDir, stylesDir);
290                         copyStyleResources(moduleStylesDir);
291 
292                         // Replace JaCoCo resources in this module
293                         themeJacocoResources(moduleSiteDir);
294 
295                         getLog().info("Processing individual module site: " + artifactId);
296                         processHtmlFiles(moduleSiteDir, moduleSiteDir);
297                     }
298                 }
299             }
300 
301             // Log statistics
302             getLog().info("Style injection complete:");
303             getLog().info("  Total HTML files processed: " + processedFiles);
304             getLog().info("  Landing pages: " + landingFiles);
305             getLog().info("  Coverage pages: " + coverageFiles);
306             getLog().info("  JXR pages: " + jxrFiles);
307             getLog().info("  Javadoc pages: " + javadocFiles);
308             getLog().info("  Site pages: " + siteFiles);
309 
310         } catch (IOException e) {
311             throw new MojoExecutionException("Failed to inject styles", e);
312         }
313     }
314 
315     /**
316      * Copies all style resources (CSS and JS) from the plugin JAR to the target directory.
317      * JavaScript files are processed for token replacement.
318      *
319      * @param targetDir the directory to copy resources to (will be created if needed)
320      * @throws IOException if file copying fails
321      */
322     private void copyStyleResources(File targetDir) throws IOException {
323         targetDir.mkdirs();
324 
325         // Copy CSS for each page type
326         for (PageType pageType : PageType.values()) {
327             copyResource(
328                     STYLES_RESOURCE_PATH + pageType.getCssFile(),
329                     new File(targetDir, pageType.getCssFile()));
330         }
331 
332         // Copy JS with token replacement
333         copyJsWithTokenReplacement(STYLES_RESOURCE_PATH + JS_FILE, new File(targetDir, JS_FILE));
334     }
335 
336     /**
337      * Copies a JavaScript file with token replacement for project branding.
338      *
339      * <p>
340      * Replaces the following tokens:
341      * <ul>
342      * <li>{@code %%PROJECT_NAME%%} - replaced with {@link #projectName}</li>
343      * <li>{@code %%PROJECT_LOGO%%} - replaced with {@link #projectLogo}</li>
344      * </ul>
345      *
346      * @param resourcePath the classpath resource path to copy from
347      * @param targetFile   the target file to copy to
348      * @throws IOException if file reading or writing fails
349      */
350     private void copyJsWithTokenReplacement(String resourcePath, File targetFile) throws IOException {
351         try (InputStream is = getResourceStream(resourcePath)) {
352             if (is != null) {
353                 // Java 8 compatible way to read all bytes from InputStream
354                 java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
355                 byte[] data = new byte[4096];
356                 int bytesRead;
357                 while ((bytesRead = is.read(data, 0, data.length)) != -1) {
358                     buffer.write(data, 0, bytesRead);
359                 }
360                 String content = new String(buffer.toByteArray(), StandardCharsets.UTF_8);
361 
362                 // Replace tokens
363                 content = content.replace("%%PROJECT_NAME%%", projectName != null ? projectName : "");
364                 content = content.replace("%%PROJECT_LOGO%%", projectLogo != null ? projectLogo : "");
365 
366                 Files.write(targetFile.toPath(), content.getBytes(StandardCharsets.UTF_8));
367                 getLog().debug("Copied JS with token replacement: " + resourcePath + " -> " + targetFile);
368                 getLog().debug("  PROJECT_NAME: " + projectName);
369                 getLog().debug("  PROJECT_LOGO: " + projectLogo);
370             } else {
371                 getLog().warn("Resource not found: " + resourcePath +
372                         ". Run 'npm run build' in css-zen-garden to generate it.");
373             }
374         }
375     }
376 
377     /**
378      * Copies a single resource file from the plugin JAR to the filesystem.
379      *
380      * @param resourcePath the classpath resource path to copy from
381      * @param targetFile   the target file to copy to
382      * @throws IOException if file copying fails
383      */
384     private void copyResource(String resourcePath, File targetFile) throws IOException {
385         try (InputStream is = getResourceStream(resourcePath)) {
386             if (is != null) {
387                 Files.copy(is, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
388                 getLog().debug("Copied resource: " + resourcePath + " -> " + targetFile);
389             } else {
390                 getLog().warn("Resource not found: " + resourcePath +
391                         ". Run 'npm run build' in css-zen-garden to generate it.");
392             }
393         }
394     }
395 
396     /**
397      * Copies themed JaCoCo resources (coverage bar images) to a jacoco-resources directory.
398      *
399      * @param jacocoResourcesDir the JaCoCo resources directory to update
400      * @throws IOException if file copying fails
401      */
402     private void copyJacocoResources(File jacocoResourcesDir) throws IOException {
403         if (!jacocoResourcesDir.exists()) {
404             return;
405         }
406 
407         for (String fileName : JACOCO_RESOURCE_FILES) {
408             copyResource(JACOCO_RESOURCES_PATH + fileName, new File(jacocoResourcesDir, fileName));
409         }
410         getLog().debug("Copied themed JaCoCo resources to: " + jacocoResourcesDir);
411     }
412 
413     /**
414      * Finds and themes all JaCoCo resource directories in the given site directory.
415      * Recursively walks the directory tree looking for {@code jacoco-resources} folders.
416      *
417      * @param siteDir the site directory to search
418      * @throws IOException if directory traversal or file copying fails
419      */
420     private void themeJacocoResources(File siteDir) throws IOException {
421         Files.walkFileTree(siteDir.toPath(), new SimpleFileVisitor<Path>() {
422             @Override
423             public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
424                 if (dir.getFileName() != null && dir.getFileName().toString().equals("jacoco-resources")) {
425                     copyJacocoResources(dir.toFile());
426                 }
427                 return FileVisitResult.CONTINUE;
428             }
429         });
430     }
431 
432     /**
433      * Gets an input stream for a classpath resource, trying multiple classloaders.
434      *
435      * <p>
436      * Attempts loading in this order:
437      * <ol>
438      * <li>Thread context classloader</li>
439      * <li>Class classloader</li>
440      * <li>Direct resource stream with leading slash</li>
441      * </ol>
442      *
443      * @param resourcePath the resource path to load
444      * @return the input stream, or {@code null} if not found
445      */
446     private InputStream getResourceStream(String resourcePath) {
447         InputStream is = Thread.currentThread()
448                 .getContextClassLoader()
449                 .getResourceAsStream(resourcePath);
450 
451         if (is == null) {
452             is = InjectSiteStylesMojo.class.getClassLoader().getResourceAsStream(resourcePath);
453         }
454 
455         if (is == null) {
456             is = InjectSiteStylesMojo.class.getResourceAsStream("/" + resourcePath);
457         }
458 
459         return is;
460     }
461 
462     /**
463      * Processes all HTML files in the given directory recursively.
464      *
465      * @param directory the directory to scan for HTML files
466      * @param siteRoot  the root of the site (for relative path calculations)
467      * @throws IOException if file traversal or processing fails
468      */
469     private void processHtmlFiles(File directory, File siteRoot) throws IOException {
470         Files.walkFileTree(directory.toPath(), new SimpleFileVisitor<Path>() {
471             @Override
472             public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
473                 if (file.toString().endsWith(".html")) {
474                     processHtmlFile(file.toFile(), siteRoot);
475                 }
476                 return FileVisitResult.CONTINUE;
477             }
478 
479             @Override
480             public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException {
481                 getLog().warn("Failed to visit file: " + file + " - " + exc.getMessage());
482                 return FileVisitResult.CONTINUE;
483             }
484         });
485     }
486 
487     /**
488      * Processes a single HTML file by detecting its type and injecting appropriate styles.
489      *
490      * @param htmlFile the HTML file to process
491      * @param siteRoot the root of the site (for relative path calculations)
492      * @throws IOException if file reading or writing fails
493      */
494     private void processHtmlFile(File htmlFile, File siteRoot) throws IOException {
495         String content = new String(Files.readAllBytes(htmlFile.toPath()), StandardCharsets.UTF_8);
496 
497         // Check if styles are already injected (avoid duplicate injection)
498         if (content.contains(INJECTION_MARKER)) {
499             getLog().debug("Skipping already injected file: " + htmlFile);
500             return;
501         }
502 
503         // Detect page type
504         PageType pageType = detectPageType(htmlFile, content);
505 
506         // Calculate relative path to styles directory
507         String relativePath = calculateRelativePath(htmlFile, siteRoot);
508 
509         // Generate the style injection snippet
510         String styleSnippet = generateStyleSnippet(pageType, relativePath);
511 
512         // Inject styles before </head>
513         String modifiedContent = injectStyles(content, styleSnippet);
514 
515         if (modifiedContent != null) {
516             Files.write(htmlFile.toPath(), modifiedContent.getBytes(StandardCharsets.UTF_8));
517             processedFiles++;
518 
519             // Update statistics
520             switch (pageType) {
521                 case LANDING:
522                     landingFiles++;
523                     break;
524                 case COVERAGE:
525                     coverageFiles++;
526                     break;
527                 case JXR:
528                     jxrFiles++;
529                     break;
530                 case JAVADOC:
531                     javadocFiles++;
532                     break;
533                 case SITE:
534                     siteFiles++;
535                     break;
536             }
537 
538             getLog().debug("Injected " + pageType.getName() + " styles into: " + htmlFile);
539         }
540     }
541 
542     /**
543      * Detects the page type based on file path and content.
544      *
545      * <p>
546      * Detection priority:
547      * <ol>
548      * <li>Filename match (coverage.html, source-xref.html)</li>
549      * <li>Path-based detection (/jacoco/, /xref/, /apidocs/)</li>
550      * <li>Content-based detection (HTML markers)</li>
551      * <li>Default to SITE type</li>
552      * </ol>
553      *
554      * @param htmlFile the HTML file being processed
555      * @param content  the file's content
556      * @return the detected page type
557      */
558     private PageType detectPageType(File htmlFile, String content) {
559         String path = htmlFile.getAbsolutePath().replace('\\', '/').toLowerCase();
560         String fileName = htmlFile.getName().toLowerCase();
561 
562         // Landing pages (generated by generate-landing-pages goal)
563         if (fileName.equals("coverage.html") || fileName.equals("source-xref.html")) {
564             return PageType.LANDING;
565         }
566 
567         // Path-based detection (most reliable)
568         if (path.contains("/jacoco/") || path.contains("/coverage/")) {
569             return PageType.COVERAGE;
570         }
571         if (path.contains("/xref/") || path.contains("/xref-test/")) {
572             return PageType.JXR;
573         }
574         if (path.contains("/apidocs/") || path.contains("/testapidocs/") || path.contains("/javadoc/")) {
575             return PageType.JAVADOC;
576         }
577 
578         // Content-based detection (fallback)
579         if (isLandingContent(content)) {
580             return PageType.LANDING;
581         }
582         if (isJacocoContent(content)) {
583             return PageType.COVERAGE;
584         }
585         if (isJxrContent(content)) {
586             return PageType.JXR;
587         }
588         if (isJavadocContent(content)) {
589             return PageType.JAVADOC;
590         }
591 
592         // Default to site
593         return PageType.SITE;
594     }
595 
596     /**
597      * Checks if content is from a landing page (coverage.html or source-xref.html).
598      *
599      * @param content the HTML content to check
600      * @return {@code true} if landing page markers are found
601      */
602     private boolean isLandingContent(String content) {
603         return content.contains("class=\"terminal-header\"") ||
604                 content.contains("class=\"terminal-brand\"") ||
605                 content.contains("class=\"module-list\"");
606     }
607 
608     /**
609      * Checks if content is from a JaCoCo coverage report.
610      *
611      * @param content the HTML content to check
612      * @return {@code true} if JaCoCo markers are found
613      */
614     private boolean isJacocoContent(String content) {
615         return content.contains("jacoco") ||
616                 content.contains("Coverage Report") ||
617                 content.contains("class=\"el_package\"") ||
618                 content.contains("class=\"el_class\"") ||
619                 content.contains("class=\"ctr2\"");
620     }
621 
622     /**
623      * Checks if content is from a JXR source cross-reference page.
624      *
625      * @param content the HTML content to check
626      * @return {@code true} if JXR markers are found
627      */
628     private boolean isJxrContent(String content) {
629         return content.contains("jxr") ||
630                 content.contains("Cross-Reference") ||
631                 content.contains("class=\"jxr_") ||
632                 content.contains("id=\"jxr_");
633     }
634 
635     /**
636      * Checks if content is from Javadoc API documentation.
637      *
638      * @param content the HTML content to check
639      * @return {@code true} if Javadoc markers are found
640      */
641     private boolean isJavadocContent(String content) {
642         return content.contains("Generated by javadoc") ||
643                 content.contains("<!-- Generated by javadoc") ||
644                 content.contains("class=\"summary-table\"") ||
645                 content.contains("class=\"member-signature\"") ||
646                 content.contains("class=\"description\"");
647     }
648 
649     /**
650      * Calculates the relative path from an HTML file's directory to the site root.
651      *
652      * @param htmlFile the HTML file being processed
653      * @param siteRoot the root directory of the site
654      * @return the relative path with trailing slash (e.g., "../../../")
655      */
656     private String calculateRelativePath(File htmlFile, File siteRoot) {
657         Path htmlPath = htmlFile.toPath().getParent();
658         Path rootPath = siteRoot.toPath();
659 
660         try {
661             Path relativePath = htmlPath.relativize(rootPath);
662             String result = relativePath.toString().replace('\\', '/');
663             if (result.isEmpty()) {
664                 return "./";
665             }
666             if (!result.endsWith("/")) {
667                 result += "/";
668             }
669             return result;
670         } catch (IllegalArgumentException e) {
671             // Paths are on different roots, use absolute
672             return "./";
673         }
674     }
675 
676     /**
677      * Generates the HTML snippet to inject for a given page type.
678      *
679      * <p>
680      * The snippet includes:
681      * <ul>
682      * <li>An HTML comment marker for detection</li>
683      * <li>A CSS link tag for the page-specific stylesheet</li>
684      * <li>A deferred script tag for the JavaScript bundle</li>
685      * </ul>
686      *
687      * @param pageType     the type of page being processed
688      * @param relativePath the relative path to the site root
689      * @return the HTML snippet to inject
690      */
691     private String generateStyleSnippet(PageType pageType, String relativePath) {
692         String stylesPath = relativePath + stylesDir + "/";
693 
694         return "\n<!-- " + INJECTION_MARKER + " [" + pageType.getName() + "] -->\n" +
695                 "<link rel=\"stylesheet\" href=\"" + stylesPath + pageType.getCssFile() + "\">\n" +
696                 "<script src=\"" + stylesPath + JS_FILE + "\" defer></script>\n";
697     }
698 
699     /**
700      * Injects the style snippet into HTML content before the closing head tag.
701      *
702      * <p>
703      * Injection strategy:
704      * <ol>
705      * <li>Try to inject before {@code </head>} (preferred)</li>
706      * <li>Fall back to after {@code <head>} if no closing tag</li>
707      * <li>Return {@code null} if no head section found</li>
708      * </ol>
709      *
710      * @param content      the HTML content to modify
711      * @param styleSnippet the style snippet to inject
712      * @return the modified content, or {@code null} if injection failed
713      */
714     private String injectStyles(String content, String styleSnippet) {
715         // Try to inject before </head>
716         Pattern headPattern = Pattern.compile("(</head>)", Pattern.CASE_INSENSITIVE);
717         Matcher matcher = headPattern.matcher(content);
718 
719         if (matcher.find()) {
720             return content.substring(0, matcher.start()) +
721                     styleSnippet +
722                     content.substring(matcher.start());
723         }
724 
725         // If no </head>, try after <head>
726         Pattern headOpenPattern = Pattern.compile("(<head[^>]*>)", Pattern.CASE_INSENSITIVE);
727         Matcher openMatcher = headOpenPattern.matcher(content);
728 
729         if (openMatcher.find()) {
730             return content.substring(0, openMatcher.end()) +
731                     styleSnippet +
732                     content.substring(openMatcher.end());
733         }
734 
735         // No head section found
736         getLog().warn("No <head> section found in HTML, cannot inject styles");
737         return null;
738     }
739 }