AsciidocMojo.java

package network.ike.docs.plugin;

import network.ike.docs.koncept.KonceptGlossaryProcessor;
import network.ike.docs.koncept.KonceptInlineMacro;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.annotations.Mojo;
import org.apache.maven.api.plugin.annotations.Parameter;
import org.apache.maven.api.plugin.Log;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Attributes;
import org.asciidoctor.AttributesBuilder;
import org.asciidoctor.Options;
import org.asciidoctor.OptionsBuilder;
import org.asciidoctor.SafeMode;
import org.asciidoctor.log.LogHandler;
import org.asciidoctor.log.LogRecord;
import org.asciidoctor.log.Severity;

import java.io.File;
import java.io.IOException;
import java.nio.file.*;
import java.util.List;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.Map;

/**
 * Generate documentation from AsciiDoc sources using AsciidoctorJ.
 *
 * <p>Replaces asciidoctor-maven-plugin with a single goal that handles
 * multiple backends in one execution. Extensions (Koncept inline macro,
 * glossary postprocessor) are registered programmatically with
 * backend-aware safety — the Prawn PDF backend does not receive
 * postprocessor extensions that would crash JRuby.
 *
 * <p>Output validation is built in: unresolved attributes, missing
 * includes, broken cross-references, and missing images are detected
 * and optionally fail the build.
 *
 * <p>Usage:
 * <pre>{@code
 * <execution>
 *     <id>generate-docs</id>
 *     <goals><goal>asciidoc</goal></goals>
 *     <configuration>
 *         <skipHtml>false</skipHtml>
 *         <skipPrawn>false</skipPrawn>
 *         <validate>true</validate>
 *     </configuration>
 * </execution>
 * }</pre>
 */
@Mojo(name = "asciidoc",
      defaultPhase = "generate-resources")
public class AsciidocMojo 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; }

    // ── Source / output ───────────────────────────────────────────────

    /** AsciiDoc source directory. */
    @Parameter(property = "ike.asciidoc.sourceDirectory",
               defaultValue = "${project.basedir}/src/docs/asciidoc")
    File sourceDirectory;

    /** Root output directory. Backends write to subdirectories. */
    @Parameter(property = "ike.asciidoc.outputDirectory",
               defaultValue = "${project.build.directory}/ike-doc")
    File outputDirectory;

    /**
     * Source document name (e.g., {@code index.adoc}).
     * When set, only this file is converted. When null, all .adoc files
     * in the source directory are converted.
     */
    @Parameter(property = "ike.asciidoc.sourceDocumentName")
    String sourceDocumentName;

    /** Base directory for relative includes. */
    @Parameter(property = "ike.asciidoc.baseDir",
               defaultValue = "${project.basedir}/src/docs/asciidoc")
    File baseDir;

    // ── Backend skip flags ────────────────────────────────────────────

    /** Skip all execution. */
    @Parameter(property = "ike.asciidoc.skip", defaultValue = "false")
    boolean skip;

    /** Skip HTML5 backend. */
    @Parameter(property = "ike.skip.html", defaultValue = "false")
    boolean skipHtml;

    /** Skip single-file HTML generation. */
    @Parameter(property = "ike.skip.html-single", defaultValue = "true")
    boolean skipHtmlSingle;

    /** Skip Prawn PDF backend. */
    @Parameter(property = "ike.skip.prawn", defaultValue = "true")
    boolean skipPrawn;

    /** Skip DocBook 5 backend. */
    @Parameter(property = "ike.skip.docbook", defaultValue = "true")
    boolean skipDocbook;

    // ── HTML options ──────────────────────────────────────────────────

    /** TOC placement for HTML output. */
    @Parameter(property = "ike.asciidoc.htmlToc", defaultValue = "auto")
    String htmlToc;

    /** TOC depth for HTML output. */
    @Parameter(property = "ike.asciidoc.htmlTocLevels", defaultValue = "3")
    int htmlTocLevels;

    // ── Prawn PDF options ─────────────────────────────────────────────

    /** Prawn theme directory. */
    @Parameter(property = "ike.asciidoc.pdfThemeDir",
               defaultValue = "${project.build.directory}/ike-doc-resources/theme")
    File pdfThemeDir;

    /** Prawn theme name. */
    @Parameter(property = "ike.asciidoc.pdfTheme", defaultValue = "ike-default")
    String pdfTheme;

    /** Font directory for Prawn PDF. */
    @Parameter(property = "ike.asciidoc.pdfFontsDir",
               defaultValue = "${project.build.directory}/fonts")
    File pdfFontsDir;

    // ── Diagram / shared resources ────────────────────────────────────

    /** Kroki diagram server URL. */
    @Parameter(property = "ike.asciidoc.diagramServerUrl",
               defaultValue = "https://kroki.komet.sh")
    String diagramServerUrl;

    /** Shared docinfo directory. */
    @Parameter(property = "ike.asciidoc.sharedAsciidocDir",
               defaultValue = "${project.build.directory}/ike-doc-resources/shared-asciidoc")
    File sharedAsciidocDir;

    /** Ruby libraries to require. Defaults to asciidoctor-diagram. */
    @Parameter
    List<String> requires = List.of("asciidoctor-diagram");

    // ── Document naming ───────────────────────────────────────────────

    /** Output document base name. */
    @Parameter(property = "ike.document.name",
               defaultValue = "${project.artifactId}")
    String documentName;

    // ── Project attributes (injected by Maven) ────────────────────────

    @Parameter(defaultValue = "${project.version}", readonly = true)
    String projectVersion;

    @Parameter(defaultValue = "${project.name}", readonly = true)
    String projectName;

    // ── Validation ────────────────────────────────────────────────────

    /** Validate output for unresolved attributes, broken xrefs, etc. */
    @Parameter(property = "ike.asciidoc.validate", defaultValue = "true")
    boolean validate;

    /** Fail the build on any validation error. */
    @Parameter(property = "ike.asciidoc.strict", defaultValue = "false")
    boolean strict;

    // ── Output mode ──────────────────────────────────────────────────

    /**
     * Generate standalone HTML documents (with head/body shell).
     * Set to {@code false} for embedded body fragments suitable for
     * Doxia integration via {@code generatedSiteDirectory}.
     */
    @Parameter(property = "ike.asciidoc.standalone", defaultValue = "true")
    boolean standalone;

    // ── Additional attributes from POM ────────────────────────────────

    /** Additional AsciiDoc attributes merged with defaults. */
    @Parameter
    Map<String, String> attributes;

    /** Creates this goal instance. */
    public AsciidocMojo() {}

    @Override
    public void execute() throws MojoException {
        if (skip) {
            getLog().info("ike:asciidoc skipped");
            return;
        }
        if (!sourceDirectory.isDirectory()) {
            getLog().info("ike:asciidoc: source directory does not exist, skipping — "
                    + sourceDirectory);
            return;
        }

        getLog().info("ike:asciidoc — source: " + sourceDirectory);

        // Suppress JRuby/AsciidoctorJ java.util.logging output to stderr.
        // Our LogHandler routes messages through Maven's logger instead.
        Logger asciidoctorLogger = Logger.getLogger("asciidoctor");
        asciidoctorLogger.setUseParentHandlers(false);
        for (Handler h : asciidoctorLogger.getHandlers()) {
            asciidoctorLogger.removeHandler(h);
        }
        asciidoctorLogger.setLevel(Level.ALL);

        try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
            // Route AsciidoctorJ log messages through Maven's logger
            asciidoctor.registerLogHandler(new MavenLogHandler(getLog()));

            // Require Ruby libraries (e.g., asciidoctor-diagram)
            if (requires != null && !requires.isEmpty()) {
                asciidoctor.requireLibrary(requires.toArray(new String[0]));
            }

            // Register Koncept inline macro (safe for all backends)
            asciidoctor.javaExtensionRegistry()
                    .inlineMacro(KonceptInlineMacro.class);

            // Convert each enabled backend
            boolean hadErrors = false;
            if (!skipHtml) {
                hadErrors |= !tryConvert(asciidoctor, Backend.HTML, false);
            }
            if (!skipHtmlSingle) {
                hadErrors |= !tryConvert(asciidoctor, Backend.HTML, true);
            }
            if (!skipPrawn) {
                hadErrors |= !tryConvert(asciidoctor, Backend.PDF, false);
            }
            if (!skipDocbook) {
                hadErrors |= !tryConvert(asciidoctor, Backend.DOCBOOK, false);
            }
            if (hadErrors && strict) {
                throw new MojoException(
                        "ike:asciidoc: one or more backends failed (strict mode)");
            }
        }

        // Validate outputs
        if (validate) {
            validateOutputs();
        }
    }

    /**
     * Attempt a backend conversion, logging errors instead of failing.
     *
     * @return true if conversion succeeded, false on error
     */
    private boolean tryConvert(Asciidoctor asciidoctor, Backend backend, boolean singleFile) {
        try {
            convertBackend(asciidoctor, backend, singleFile);
            return true;
        } catch (MojoException e) {
            getLog().error("ike:asciidoc: " + backend + " conversion failed — "
                    + e.getMessage());
            return false;
        }
    }

    /**
     * Convert AsciiDoc sources for a single backend.
     *
     * @param asciidoctor the AsciidoctorJ instance
     * @param backend     target backend
     * @param singleFile  if true, generate single-file HTML (data-uri)
     */
    private void convertBackend(Asciidoctor asciidoctor, Backend backend, boolean singleFile)
            throws MojoException {

        File outDir;
        if (!standalone && backend == Backend.HTML && !singleFile) {
            // Embedded mode: write directly to outputDirectory for Doxia integration
            outDir = outputDirectory;
        } else {
            String subdir = singleFile ? "html-single" : backend.outputSubdir();
            outDir = new File(outputDirectory, subdir);
        }
        outDir.mkdirs();

        getLog().info("  backend: " + backend.asciidoctorName()
                + (singleFile ? " (single-file)" : "")
                + " → " + outDir);

        // Register/unregister postprocessor per backend
        if (backend.supportsPostprocessor()) {
            asciidoctor.javaExtensionRegistry()
                    .postprocessor(KonceptGlossaryProcessor.class);
        }

        try {
            Attributes attrs = buildAttributes(backend, singleFile);
            Options options = buildOptions(backend, outDir, attrs, singleFile);

            if (sourceDocumentName != null && (singleFile || backend == Backend.PDF)) {
                // Single document conversion
                File sourceFile = new File(sourceDirectory, sourceDocumentName);
                if (!sourceFile.isFile()) {
                    throw new MojoException(
                            "Source document not found: " + sourceFile);
                }
                asciidoctor.convertFile(sourceFile, options);
            } else {
                // Convert all .adoc files in source directory
                List<File> sourceFiles = scanAsciiDocFiles(sourceDirectory.toPath());
                for (File src : sourceFiles) {
                    asciidoctor.convertFile(src, options);
                }
            }
            // Embedded mode: rename .html → .xhtml for Doxia XHTML parser
            if (!standalone && backend == Backend.HTML && !singleFile) {
                renameHtmlToXhtml(outDir);
            }
        } catch (MojoException e) {
            throw e;
        } catch (Exception e) {
            throw new MojoException(
                    "AsciidoctorJ conversion failed for backend " + backend, e);
        } finally {
            // Unregister postprocessor to prevent it from running on next backend
            if (backend.supportsPostprocessor()) {
                asciidoctor.unregisterAllExtensions();
                // Re-register the inline macro (unregisterAll removes everything)
                asciidoctor.javaExtensionRegistry()
                        .inlineMacro(KonceptInlineMacro.class);
            }
        }
    }

    /**
     * Build the AsciiDoc attributes for a given backend.
     */
    private Attributes buildAttributes(Backend backend, boolean singleFile) {
        AttributesBuilder ab = Attributes.builder();

        // Common attributes
        ab.attribute("project-version", projectVersion);
        ab.attribute("project-name", projectName);
        ab.attribute("source-highlighter", "coderay");
        ab.attribute("coderay-linenums-mode", "table");
        ab.attribute("icons", "font");
        ab.attribute("sectanchors", "true");
        ab.attribute("idprefix", "");
        ab.attribute("idseparator", "-");
        ab.attribute("allow-uri-read", "true");

        // Diagram attributes
        ab.attribute("diagram-server-url", diagramServerUrl);
        ab.attribute("diagram-server-type", "kroki_io");
        ab.attribute("diagram-format", "svg");
        ab.attribute("diagram-svg-type", "inline");
        // Disable PlantUML preprocessing — requires a companion service
        // (/plantumlpreprocessor) that self-hosted Kroki typically lacks.
        // Preprocessing is only needed for !include directives; standard
        // PlantUML renders fine without it.
        ab.attribute("plantuml-preprocess", "false");
        // Also set the non-prefixed form (block-level attribute override)
        ab.attribute("preprocess", "false");

        // Generated sources directory
        ab.attribute("generated",
                new File(outputDirectory.getParentFile(), "generated-sources/asciidoc")
                        .getAbsolutePath());
        ab.attribute("resources",
                new File(sourceDirectory.getParentFile(), "resources").getAbsolutePath());

        // Backend-specific attributes
        switch (backend) {
            case HTML -> {
                ab.attribute("toc", htmlToc);
                ab.attribute("toclevels", String.valueOf(htmlTocLevels));
                ab.linkCss(false);
                if (sharedAsciidocDir.isDirectory()) {
                    ab.attribute("docinfodir", sharedAsciidocDir.getAbsolutePath());
                    ab.attribute("docinfo", "shared");
                }
                ab.attribute("ike-pdf-renderer", "html5");
                if (singleFile) {
                    ab.dataUri(true);
                }
            }
            case PDF -> {
                ab.attribute("pdf-themesdir", pdfThemeDir.getAbsolutePath());
                ab.attribute("pdf-theme", pdfTheme);
                ab.attribute("pdf-fontsdir", pdfFontsDir.getAbsolutePath());
                ab.attribute("media", "prepress");
                ab.attribute("optimize", "true");
                ab.attribute("ike-pdf-renderer", "prawn");
            }
            case DOCBOOK -> {
                ab.attribute("doctype", "book");
                ab.attribute("ike-pdf-renderer", "docbook");
            }
        }

        // Merge user-provided attributes (override defaults)
        if (attributes != null) {
            attributes.forEach(ab::attribute);
        }

        return ab.build();
    }

    /**
     * Build AsciidoctorJ Options for a given backend and output directory.
     */
    private Options buildOptions(Backend backend, File outDir,
                                 Attributes attrs, boolean singleFile) {
        OptionsBuilder builder = Options.builder()
                .backend(backend.asciidoctorName())
                .toDir(outDir)
                .safe(SafeMode.UNSAFE)
                .mkDirs(true)
                .standalone(standalone)
                .baseDir(baseDir)
                .attributes(attrs);

        // For single-document modes, name the output using documentName
        // (e.g., "example-project.html" instead of "index.html")
        if (sourceDocumentName != null && (singleFile || backend == Backend.PDF)) {
            builder.toFile(new File(outDir,
                    documentName + "." + outputExtension(backend)));
        }

        return builder.build();
    }

    /** HTML void elements that must be self-closed for XHTML compliance. */
    private static final java.util.regex.Pattern VOID_ELEMENT =
            java.util.regex.Pattern.compile(
                    "<(area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)"
                    + "(\\s[^>]*)?>",
                    java.util.regex.Pattern.CASE_INSENSITIVE);

    /**
     * Convert embedded HTML fragments to Doxia-compatible XHTML files:
     * wrap in a single root {@code <div>}, self-close void elements,
     * and rename {@code .html → .xhtml} so the Doxia XHTML parser
     * discovers them in {@code generatedSiteDirectory/xhtml/}.
     */
    private void renameHtmlToXhtml(File dir) throws MojoException {
        File[] htmlFiles = dir.listFiles((d, name) -> name.endsWith(".html"));
        if (htmlFiles == null) return;
        for (File html : htmlFiles) {
            try {
                String content = Files.readString(html.toPath());
                // Self-close void elements: <br> → <br/>, <col ...> → <col .../>
                content = VOID_ELEMENT.matcher(content).replaceAll(mr -> {
                    String tag = mr.group(1);
                    String attrs = mr.group(2);
                    if (attrs == null || attrs.isEmpty()) return "<" + tag + "/>";
                    // Strip trailing space before closing
                    return "<" + tag + attrs.stripTrailing() + "/>";
                });
                // Wrap in root <div> with an <h1> title (Sentry skin
                // expects h1 for navigation). Extract title from first
                // <h2> if present, or use the filename.
                String title = extractTitle(content, html.getName());
                String wrapped = "<div class=\"ike-asciidoc\">\n"
                        + "<h1 id=\"doc-title\">" + title + "</h1>\n"
                        + content + "\n</div>\n";
                String xhtmlName = html.getName().replaceFirst("\\.html$", ".xhtml");
                Path xhtml = html.toPath().resolveSibling(xhtmlName);
                Files.writeString(xhtml, wrapped);
                Files.delete(html.toPath());
                getLog().debug("Wrapped and renamed " + html.getName() + " → " + xhtmlName);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to convert " + html + " to XHTML", e);
            }
        }
    }

    /** Extract a title from embedded HTML content for use as the page h1. */
    private String extractTitle(String html, String fallbackFilename) {
        // Asciidoctor's embedded mode omits the doc title (= Title) but
        // includes section titles as <h2>. The first <h2> is usually the
        // first section heading, not the doc title. Instead, look for the
        // document title in the preamble or use the AsciiDoc = Title which
        // Asciidoctor puts in a <h1> with id="document-title" even in
        // embedded mode if showtitle is set. Failing that, derive from
        // the source filename.
        var h1 = java.util.regex.Pattern.compile("<h1[^>]*>([^<]+)</h1>")
                .matcher(html);
        if (h1.find()) return h1.group(1);
        // Fall back to filename without extension
        return fallbackFilename.replaceFirst("\\.[^.]+$", "")
                .replace('-', ' ').replace('_', ' ');
    }

    private String outputExtension(Backend backend) {
        return switch (backend) {
            case HTML -> "html";
            case PDF -> "pdf";
            case DOCBOOK -> "xml";
        };
    }

    /**
     * Run output validation on all generated directories.
     */
    private void validateOutputs() throws MojoException {
        OutputValidator validator = new OutputValidator();
        List<OutputValidator.Issue> allIssues = new java.util.ArrayList<>();

        for (Backend backend : Backend.values()) {
            boolean shouldCheck = switch (backend) {
                case HTML -> !skipHtml;
                case PDF -> !skipPrawn;
                case DOCBOOK -> !skipDocbook;
            };
            if (!shouldCheck) continue;

            Path dir = outputDirectory.toPath().resolve(backend.outputSubdir());
            try {
                List<OutputValidator.Issue> issues = validator.validate(dir, backend);
                allIssues.addAll(issues);
            } catch (IOException e) {
                getLog().warn("Validation failed for " + dir + ": " + e.getMessage());
            }
        }

        if (!allIssues.isEmpty()) {
            long errors = allIssues.stream()
                    .filter(i -> i.severity() == OutputValidator.Severity.ERROR)
                    .count();
            long warnings = allIssues.stream()
                    .filter(i -> i.severity() == OutputValidator.Severity.WARNING)
                    .count();

            for (OutputValidator.Issue issue : allIssues) {
                if (issue.severity() == OutputValidator.Severity.ERROR) {
                    getLog().error(issue.toString());
                } else {
                    getLog().warn(issue.toString());
                }
            }

            getLog().info("Validation: " + errors + " error(s), " + warnings + " warning(s)");

            if (strict && errors > 0) {
                throw new MojoException(
                        "ike:asciidoc validation failed with " + errors + " error(s)");
            }
        } else {
            getLog().info("Validation: clean");
        }
    }

    // ── Log handler ────────────────────────────────────────────────

    /**
     * Routes AsciidoctorJ log messages through Maven's logger with
     * proper severity mapping. Eliminates the raw
     * {@code [WARNING] [stderr] SEVERE:} output from JRuby.
     */
    static class MavenLogHandler implements LogHandler {

        private final Log log;
        private final java.util.Set<String> seen = new java.util.HashSet<>();

        MavenLogHandler(Log log) {
            this.log = log;
        }

        @Override
        public void log(LogRecord record) {
            String source = "";
            if (record.getCursor() != null && record.getCursor().getFile() != null) {
                source = record.getCursor().getFile();
                if (record.getCursor().getLineNumber() > 0) {
                    source += ":" + record.getCursor().getLineNumber();
                }
                source += " — ";
            }
            String msg = source + record.getMessage();

            // Deduplicate: AsciidoctorJ logs the same error from parser and converter
            String key = record.getSeverity() + "|" + msg;
            if (!seen.add(key)) return;

            Severity severity = record.getSeverity();
            if (severity == Severity.ERROR || severity == Severity.FATAL) {
                log.error(msg);
            } else if (severity == Severity.WARN) {
                log.warn(msg);
            } else if (severity == Severity.INFO) {
                log.info(msg);
            } else {
                log.debug(msg);
            }
        }
    }

    // ── File scanning ───────────────────────────────────────────────

    /**
     * Scan a directory for .adoc files, excluding partials (files whose
     * name starts with underscore) and files in underscore-prefixed
     * directories.
     *
     * @param root the directory to scan
     * @return list of .adoc files
     */
    static List<File> scanAsciiDocFiles(Path root) {
        try (var stream = Files.walk(root)) {
            return stream
                    .filter(p -> p.toString().endsWith(".adoc"))
                    .filter(p -> !p.getFileName().toString().startsWith("_"))
                    .filter(p -> {
                        // Exclude files in _partial directories
                        for (Path part : root.relativize(p)) {
                            if (part.toString().startsWith("_")) return false;
                        }
                        return true;
                    })
                    .map(Path::toFile)
                    .toList();
        } catch (IOException e) {
            throw new RuntimeException("Failed to scan " + root, e);
        }
    }
}