OrgSiteSupport.java

package network.ike.plugin;

import org.apache.maven.api.plugin.Log;
import org.apache.maven.api.plugin.MojoException;
import org.apache.maven.api.plugin.MojoException;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Attributes;
import org.asciidoctor.Options;
import org.asciidoctor.SafeMode;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

/**
 * Shared utilities for org-site registration and deregistration.
 *
 * <p>Handles cloning the org site repository, writing/deleting project
 * fragments, regenerating the master index, rendering AsciiDoc to
 * XHTML, and publishing the built site to GitHub Pages.
 *
 * <p>Parallel to {@link ReleaseSupport} — all subprocess invocations
 * use {@link ProcessBuilder}, no library dependencies beyond the JDK,
 * maven-plugin-api, and AsciidoctorJ.
 */
public final class OrgSiteSupport {

    /**
     * Default Git URL for the org-site SOURCE repository. The source
     * repo has the Maven pom + src/site/ tree and is where fragments
     * are written.
     */
    static final String SRC_REPO_DEFAULT =
            "https://github.com/IKE-Network/ike-network-site.git";

    /**
     * Default Git URL for the org-site PUBLISH repository. The publish
     * repo holds only the rendered HTML and is what GitHub Pages
     * serves at https://ike.network/. Publish flow: clone, wipe
     * everything except .git, copy target/site/ from the source build,
     * commit, push.
     */
    static final String PUB_REPO_DEFAULT =
            "https://github.com/IKE-Network/IKE-Network.github.io.git";

    /**
     * @deprecated Use {@link #SRC_REPO_DEFAULT} for the source repo
     *             and {@link #PUB_REPO_DEFAULT} for the publish repo.
     *             Pre-#367 the workflow assumed a single repo serving
     *             both roles; that never worked because the publish
     *             repo has no pom. Kept for legacy callers.
     */
    @Deprecated
    static final String ORG_REPO_DEFAULT =
            "https://github.com/IKE-Network/IKE-Network.github.io.git";

    /** Directory within the org repo that holds project fragments. */
    static final String FRAGMENT_DIR = "projects";

    /** Path to the master index AsciiDoc source, relative to repo root. */
    private static final String INDEX_ADOC = "src/site/asciidoc/index.adoc";

    /** Path to the site descriptor, relative to repo root. */
    private static final String SITE_XML = "src/site/site.xml";

    /** GitHub organization that hosts the foundation repos. */
    private static final String GH_ORG_URL = "https://github.com/IKE-Network";

    /** Branch used for rendered site content (GitHub Pages source). */
    private static final String GH_PAGES_BRANCH = "gh-pages";

    /**
     * The IKE foundation projects, in parent-tier order, mapped to
     * their Maven Central coordinates ({@code groupId:artifactId}).
     *
     * <p>Membership drives the generated landing page: a project in
     * this map renders under the {@code Foundation} section with a
     * Maven Central version badge; every other registered project
     * renders under {@code Examples}. The foundation is a small,
     * deliberately fixed set — adding a member is a release-cascade
     * decision, not a routine registration, so it lives in code.
     */
    static final Map<String, String> FOUNDATION = foundationCoordinates();

    private static Map<String, String> foundationCoordinates() {
        // Order here drives the rendered Foundation section ordering on
        // https://ike.network/. Slot each entry where the project sits
        // in the dependency direction (upstream → downstream): every
        // entry below depends on every entry above.
        Map<String, String> m = new LinkedHashMap<>();
        m.put("ike-base-parent",         "network.ike:ike-base-parent");
        // Tier-0 zero-dependency value types (ConstantBackedEnum,
        // EnumDefinition, ReleasePolicy). Depended on by ike-tooling
        // (IKE-Network/ike-issues#498). Listed early since it sits
        // above ike-tooling in the dependency direction.
        m.put("ike-java-support",        "network.ike:ike-java-support");
        m.put("ike-tooling",             "network.ike.tooling:ike-tooling");
        m.put("ike-docs",                "network.ike.docs:ike-docs");
        // Standalone Tier-0 artifact consumed by ike-platform at
        // workspace runtime (#460). Listed before platform so the
        // section reads in dependency order.
        m.put("ike-workspace-extension", "network.ike.tooling:ike-workspace-extension");
        // Maven 4 build extension consumers register via
        // .mvn/extensions.xml. Validates canonical typed-marker pins
        // (${G__GA__A__VERSION}) and the ${G__GA__A__POLICY} ladder
        // shipped in #498/#525. Not in the consumer-coordinate
        // dependency direction (registered, not resolved) so its
        // order relative to its siblings is by logical grouping, not
        // by dep direction.
        m.put("ike-version-management-extension",
                "network.ike.tooling:ike-version-management-extension");
        m.put("ike-platform",            "network.ike.platform:ike-platform");
        return Collections.unmodifiableMap(m);
    }

    /**
     * Build the Maven Central version-badge AsciiDoc for a project.
     *
     * @param artifactId the project artifact ID
     * @return an AsciiDoc {@code image:} macro for foundation
     *         projects, or {@code null} if the project is not a
     *         foundation member
     */
    static String mavenCentralBadge(String artifactId) {
        String coordinates = FOUNDATION.get(artifactId);
        if (coordinates == null) {
            return null;
        }
        String path = coordinates.replace(':', '/');
        return "image:https://img.shields.io/maven-central/v/" + path
                + "[Maven Central,link="
                + "https://central.sonatype.com/artifact/" + path + "]";
    }

    /**
     * File name of the foundation build/release dependency diagram, a
     * pre-rendered static SVG that lives with the org-site content in
     * the org repo at {@code src/site/resources/images/} and is embedded
     * into the landing-page preamble by {@link #regenerateIndex}.
     *
     * <p>Per {@code IKE-DIAGRAMS.md} (site pages), diagrams on Maven site
     * pages are committed static SVG referenced with {@code image::} — the
     * Doxia site parser runs no diagram extension, and a live Kroki URL
     * would make every page view depend on a diagram service. The diagram
     * source (GraphViz) is kept beside the SVG in the org repo.
     * IKE-Network/ike-issues#797.
     */
    static final String FOUNDATION_DIAGRAM_SVG =
            "images/foundation-dependency.svg";

    private OrgSiteSupport() {}

    // ── Fragment I/O ─────────────────────────────────────────────────

    /**
     * Write a project registration fragment to the org repo.
     *
     * <p>Creates the {@code projects/} directory if absent. Overwrites
     * any existing fragment for the same artifact ID (re-registration
     * on version bump).
     *
     * @param orgRoot       root of the cloned org repository
     * @param artifactId    Maven artifact ID (used as filename)
     * @param name          human-readable project name
     * @param description   one-line project description
     * @param version       release version (not SNAPSHOT)
     * @param siteUrl       public site URL (e.g., https://ike.network/ike-pipeline/)
     * @param githubUrl     GitHub repository URL
     * @param modules       reactor module names (may be empty)
     * @throws MojoException if the fragment cannot be written
     */
    public static void writeFragment(File orgRoot, String artifactId,
                                      String name, String description,
                                      String version, String siteUrl,
                                      String githubUrl, List<String> modules)
            throws MojoException {
        Path fragmentDir = orgRoot.toPath().resolve(FRAGMENT_DIR);
        try {
            Files.createDirectories(fragmentDir);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create fragment directory: " + fragmentDir, e);
        }

        String siteLabel = siteUrl.replaceFirst("^https?://", "")
                .replaceFirst("/$", "");
        String githubLabel = githubUrl.replaceFirst("^https?://github\\.com/", "")
                .replaceFirst("/$", "");

        StringBuilder sb = new StringBuilder();
        sb.append("// IKE Project Registration Fragment\n");
        sb.append("// Managed by ").append(IkeGoal.SITE_PUBLISH.qualified())
                .append(" — do not edit manually.\n");
        sb.append("//\n");
        sb.append("// project-id: ").append(artifactId).append('\n');
        sb.append("// project-version: ").append(version).append('\n');
        sb.append("// project-url: ").append(siteUrl).append('\n');
        sb.append("// github-url: ").append(githubUrl).append('\n');
        sb.append("// registered: ").append(Instant.now()).append('\n');
        sb.append('\n');
        sb.append("= ").append(name).append('\n');
        sb.append('\n');
        String badge = mavenCentralBadge(artifactId);
        if (badge != null) {
            sb.append(badge).append('\n');
            sb.append('\n');
        }
        if (description != null && !description.isBlank()) {
            sb.append(description).append('\n');
            sb.append('\n');
        }
        sb.append("[cols=\"1,2\",options=\"autowidth\"]\n");
        sb.append("|===\n");
        sb.append("| Version | ").append(version).append('\n');
        sb.append("| Site | ").append(siteUrl).append('[')
                .append(siteLabel).append("]\n");
        sb.append("| GitHub | ").append(githubUrl).append('[')
                .append(githubLabel).append("]\n");
        sb.append("|===\n");

        if (modules != null && !modules.isEmpty()) {
            sb.append('\n');
            sb.append("=== Modules\n");
            sb.append('\n');
            for (String module : modules) {
                sb.append("* ").append(module).append('\n');
            }
        }

        Path fragmentFile = fragmentDir.resolve(artifactId + ".adoc");
        try {
            Files.writeString(fragmentFile, sb.toString(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not write fragment: " + fragmentFile, e);
        }
    }

    /**
     * Delete a project registration fragment.
     *
     * @param orgRoot    root of the cloned org repository
     * @param artifactId Maven artifact ID (filename without extension)
     * @throws MojoException if the fragment does not exist or cannot be deleted
     */
    public static void deleteFragment(File orgRoot, String artifactId)
            throws MojoException {
        Path fragmentFile = orgRoot.toPath()
                .resolve(FRAGMENT_DIR).resolve(artifactId + ".adoc");
        if (!Files.exists(fragmentFile)) {
            throw new MojoException(
                    "No registration fragment found: " + fragmentFile);
        }
        try {
            Files.delete(fragmentFile);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not delete fragment: " + fragmentFile, e);
        }
    }

    // ── Index generation ─────────────────────────────────────────────

    /**
     * Regenerate {@code src/site/asciidoc/index.adoc} from all fragments
     * in the {@code projects/} directory.
     *
     * <p>Registered projects are split into two sections: foundation
     * members (see {@link #FOUNDATION}, rendered in parent-tier order)
     * and everything else (the examples, rendered alphabetically). The
     * index preamble and section intros are embedded here as a
     * template.
     *
     * @param orgRoot root of the cloned org repository
     * @throws MojoException if fragments cannot be read or index cannot be written
     */
    public static void regenerateIndex(File orgRoot)
            throws MojoException {
        Path fragmentDir = orgRoot.toPath().resolve(FRAGMENT_DIR);
        List<String> fragmentNames = new ArrayList<>();

        if (Files.isDirectory(fragmentDir)) {
            try (DirectoryStream<Path> stream =
                         Files.newDirectoryStream(fragmentDir, "*.adoc")) {
                for (Path entry : stream) {
                    fragmentNames.add(entry.getFileName().toString());
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Could not scan fragment directory: " + fragmentDir, e);
            }
        }
        Collections.sort(fragmentNames);

        // Foundation members in parent-tier order; everything else
        // (the examples) keeps the alphabetical order.
        List<String> foundation = new ArrayList<>();
        for (String id : FOUNDATION.keySet()) {
            String fragment = id + ".adoc";
            if (fragmentNames.contains(fragment)) {
                foundation.add(fragment);
            }
        }
        List<String> examples = new ArrayList<>();
        for (String fragment : fragmentNames) {
            if (!foundation.contains(fragment)) {
                examples.add(fragment);
            }
        }

        StringBuilder sb = new StringBuilder();
        sb.append("= IKE Network\n");
        sb.append(":icons: font\n");
        sb.append('\n');
        sb.append("The IKE Network (Integrated Knowledge Exchange) is a sociotechnical\n");
        sb.append("fabric where knowledge compounds.\n");
        sb.append('\n');

        if (!foundation.isEmpty()) {
            sb.append("== Foundation\n");
            sb.append('\n');
            sb.append("The IKE foundation — published to Maven Central and\n");
            sb.append("inheritable by any project.\n");
            sb.append('\n');
            sb.append("The foundation members fall into two layered orderings.\n");
            sb.append("`ike-base-parent` sits at the apex of the *parent*\n");
            sb.append("inheritance chain — every other foundation artifact\n");
            sb.append("inherits from it. `ike-platform` sits at the terminus\n");
            sb.append("of the *build-and-release* dependency chain — releases\n");
            sb.append("propagate through it last.\n");
            sb.append('\n');
            sb.append(".Build/release dependency order\n");
            sb.append("image::")
                    .append(FOUNDATION_DIAGRAM_SVG)
                    .append("[Build/release dependency order]\n");
            sb.append('\n');
            sb.append("Members at the same level have no dependency on each\n");
            sb.append("other — `ike-tooling` and `ike-workspace-extension`\n");
            sb.append("can release in either order or in parallel.\n");
            sb.append('\n');
            appendIncludes(sb, foundation);
        }

        if (!examples.isEmpty()) {
            sb.append("== Examples\n");
            sb.append('\n');
            sb.append("Reference projects that show how to consume the IKE\n");
            sb.append("foundation. They are deliberately *not* published to\n");
            sb.append("Maven Central: they are worked examples to read and\n");
            sb.append("copy, not libraries to depend on. Publishing them as\n");
            sb.append("artifacts would invite accidental coupling to code\n");
            sb.append("that exists only to illustrate a pattern.\n");
            sb.append('\n');
            appendIncludes(sb, examples);
        }

        Path indexFile = orgRoot.toPath().resolve(INDEX_ADOC);
        try {
            Files.createDirectories(indexFile.getParent());
            Files.writeString(indexFile, sb.toString(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not write index: " + indexFile, e);
        }
    }

    /**
     * Append {@code include::} directives for the given fragment
     * filenames to the index buffer, one per line with a trailing
     * blank line. The path is relative to {@code src/site/asciidoc/}.
     *
     * @param sb        the index buffer being assembled
     * @param fragments fragment filenames (e.g. {@code ike-docs.adoc})
     */
    private static void appendIncludes(StringBuilder sb,
                                       List<String> fragments) {
        for (String fragment : fragments) {
            sb.append("include::../../../projects/")
                    .append(fragment)
                    .append("[leveloffset=+1]\n");
            sb.append('\n');
        }
    }

    // ── Site descriptor (site.xml) regeneration ──────────────────────

    /**
     * Regenerate the {@code <menu>} blocks in the org-site's
     * {@code src/site/site.xml} from the same fragment-list that
     * drives the index body — eliminating the drift class where the
     * landing-page body is auto-current but the left-nav is a stale
     * hand-maintained snapshot (IKE-Network/ike-issues#520).
     *
     * <p>Three menu blocks are rewritten in place:
     * <ul>
     *   <li>{@code <menu name="Foundation">} — entries in
     *       {@link #FOUNDATION} order, intersected with the
     *       fragments actually present on disk.</li>
     *   <li>{@code <menu name="Examples">} — fragments not in
     *       {@code FOUNDATION}, in alphabetical order.</li>
     *   <li>{@code <menu name="Source">} — foundation + examples,
     *       pointing at GitHub repo URLs.</li>
     * </ul>
     *
     * <p>Everything else in {@code site.xml} — skin coordinates,
     * banners, breadcrumbs, custom block, the licence comment — is
     * preserved by find-and-replace on each labelled menu block.
     *
     * <p>No-op when {@code src/site/site.xml} is absent. Called from
     * {@link #registerProject} and {@link #deregisterProject} after
     * {@link #regenerateIndex}; the body and left-nav update in the
     * same registration step.
     *
     * @param orgRoot root of the cloned org repository
     * @throws MojoException if the descriptor cannot be read or written
     */
    public static void regenerateSiteXml(File orgRoot)
            throws MojoException {
        Path siteXml = orgRoot.toPath().resolve(SITE_XML);
        if (!Files.exists(siteXml)) {
            return;
        }

        Path fragmentDir = orgRoot.toPath().resolve(FRAGMENT_DIR);
        List<String> fragmentIds = new ArrayList<>();
        if (Files.isDirectory(fragmentDir)) {
            try (DirectoryStream<Path> stream =
                         Files.newDirectoryStream(fragmentDir, "*.adoc")) {
                for (Path entry : stream) {
                    String name = entry.getFileName().toString();
                    fragmentIds.add(name.substring(0,
                            name.length() - ".adoc".length()));
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Could not scan fragment directory: " + fragmentDir, e);
            }
        }
        Collections.sort(fragmentIds);

        List<String> foundation = new ArrayList<>();
        for (String id : FOUNDATION.keySet()) {
            if (fragmentIds.contains(id)) {
                foundation.add(id);
            }
        }
        List<String> examples = new ArrayList<>();
        for (String id : fragmentIds) {
            if (!foundation.contains(id)) {
                examples.add(id);
            }
        }

        String original;
        try {
            original = Files.readString(siteXml, StandardCharsets.UTF_8);
        } catch (IOException e) {
            throw new MojoException("Could not read " + siteXml, e);
        }

        String updated = replaceMenu(original, "Foundation",
                renderSiteMenu("Foundation", foundation,
                        id -> "https://ike.network/" + id + "/",
                        OrgSiteSupport::displayTitle));
        updated = replaceMenu(updated, "Examples",
                renderSiteMenu("Examples", examples,
                        id -> "https://ike.network/" + id + "/",
                        id -> id));
        List<String> all = new ArrayList<>(foundation);
        all.addAll(examples);
        updated = replaceMenu(updated, "Source",
                renderSiteMenu("Source", all,
                        id -> GH_ORG_URL + "/" + id,
                        id -> id));

        if (!updated.equals(original)) {
            try {
                Files.writeString(siteXml, updated, StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new MojoException("Could not write " + siteXml, e);
            }
        }
    }

    /**
     * Render one {@code <menu>} block. Pure function — testable
     * without any I/O.
     *
     * @param name        menu name attribute (e.g. {@code "Foundation"})
     * @param ids         artifact IDs to render as menu items
     * @param hrefFn      maps an artifact ID to its menu-item URL
     * @param titleFn     maps an artifact ID to its menu-item display text
     * @return the {@code <menu>...</menu>} block as text, ready to
     *         substitute in for the corresponding old block
     */
    static String renderSiteMenu(String name, List<String> ids,
                                 java.util.function.Function<String, String> hrefFn,
                                 java.util.function.Function<String, String> titleFn) {
        StringBuilder sb = new StringBuilder();
        sb.append("        <menu name=\"").append(name).append("\">\n");
        for (String id : ids) {
            sb.append("            <item name=\"")
                    .append(titleFn.apply(id))
                    .append("\"\n                  href=\"")
                    .append(hrefFn.apply(id))
                    .append("\"/>\n");
        }
        sb.append("        </menu>");
        return sb.toString();
    }

    /**
     * Find the {@code <menu name="X">...</menu>} block in {@code src}
     * and replace it with {@code replacement}. Returns {@code src}
     * unchanged if no matching menu block is found — the existing
     * descriptor is then assumed to already lack the block by
     * design.
     *
     * @param src         existing site.xml content
     * @param menuName    the {@code name=} attribute to locate
     * @param replacement the new block (as produced by
     *                    {@link #renderSiteMenu})
     * @return updated content
     */
    static String replaceMenu(String src, String menuName,
                              String replacement) {
        java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(
                "[ \\t]*<menu name=\"" + java.util.regex.Pattern.quote(menuName)
                        + "\">[\\s\\S]*?</menu>",
                java.util.regex.Pattern.MULTILINE);
        java.util.regex.Matcher matcher = pattern.matcher(src);
        if (!matcher.find()) {
            return src;
        }
        return matcher.replaceFirst(
                java.util.regex.Matcher.quoteReplacement(replacement));
    }

    /**
     * Turn an artifact ID like {@code ike-base-parent} into a
     * display title like {@code "IKE Base Parent"}. The {@code IKE}
     * prefix capitalizes as a known acronym; other tokens use
     * Title-Case.
     *
     * @param artifactId the artifact ID (kebab-case)
     * @return Title-Case display string
     */
    static String displayTitle(String artifactId) {
        String[] parts = artifactId.split("-");
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < parts.length; i++) {
            if (i > 0) sb.append(' ');
            String word = parts[i];
            if ("ike".equals(word)) {
                sb.append("IKE");
            } else if (!word.isEmpty()) {
                sb.append(Character.toUpperCase(word.charAt(0)))
                        .append(word.substring(1));
            }
        }
        return sb.toString();
    }

    // ── AsciiDoc rendering ───────────────────────────────────────────

    /**
     * Render the master index AsciiDoc to XHTML using AsciidoctorJ
     * in-process.
     *
     * <p>Output is placed in
     * {@code target/generated-site/xhtml/index.xhtml} within the
     * org repo clone. The Maven site plugin picks this up via
     * {@code <generatedSiteDirectory>}.
     *
     * @param orgRoot root of the cloned org repository
     * @param log     Maven logger
     * @throws MojoException if rendering fails
     */
    public static void renderToXhtml(File orgRoot, Log log)
            throws MojoException {
        Path indexAdoc = orgRoot.toPath().resolve(INDEX_ADOC);
        if (!Files.exists(indexAdoc)) {
            throw new MojoException(
                    "Index AsciiDoc not found: " + indexAdoc);
        }

        Path xhtmlDir = orgRoot.toPath()
                .resolve("target").resolve("generated-site").resolve("xhtml");
        try {
            Files.createDirectories(xhtmlDir);
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create XHTML output directory: " + xhtmlDir, e);
        }

        log.info("Rendering index.adoc to XHTML...");
        try (Asciidoctor asciidoctor = Asciidoctor.Factory.create()) {
            Attributes attrs = Attributes.builder()
                    .attribute("icons", "font")
                    .attribute("source-highlighter", "coderay")
                    .attribute("sectanchors", "true")
                    .attribute("idprefix", "")
                    .attribute("idseparator", "-")
                    .build();

            Options options = Options.builder()
                    .safe(SafeMode.UNSAFE)
                    .backend("html5")
                    .baseDir(indexAdoc.getParent().toFile())
                    .toDir(xhtmlDir.toFile())
                    .standalone(false)
                    .attributes(attrs)
                    .build();

            asciidoctor.convertFile(indexAdoc.toFile(), options);

            // Rename .html → .xhtml for Doxia XHTML parser
            Path htmlFile = xhtmlDir.resolve("index.html");
            Path xhtmlFile = xhtmlDir.resolve("index.xhtml");
            if (Files.exists(htmlFile)) {
                Files.move(htmlFile, xhtmlFile, StandardCopyOption.REPLACE_EXISTING);
            }
        } catch (IOException e) {
            throw new MojoException(
                    "Failed to rename rendered index to .xhtml", e);
        }
    }

    // ── Site build ───────────────────────────────────────────────────

    /**
     * Build the org site by invoking {@code mvn site} in the cloned
     * org repo. Output lands at {@code target/site/}.
     *
     * <p>Note: only {@code site} is invoked, not {@code site:stage}.
     * {@code site:stage} requires {@code <distributionManagement>}
     * in the project pom to compute relative paths; the org-site
     * source pom intentionally omits it because the org site has no
     * Nexus deployment target — it ships only as static HTML to the
     * publish repo. {@code mvn site} alone produces the rendered
     * {@code target/site/} tree that publishToPubRepo then ships.
     *
     * @param orgRoot root of the cloned org repository
     * @param log     Maven logger
     * @throws MojoException if the build fails
     */
    public static void buildSite(File orgRoot, Log log)
            throws MojoException {
        File mvnw = resolveMaven(orgRoot, log);
        log.info("Building org site...");
        ReleaseSupport.exec(orgRoot, log,
                mvnw.getAbsolutePath(), "site", "-B");
    }

    // ── GitHub Pages publishing ──────────────────────────────────────

    /**
     * Publish the staged site to the {@code gh-pages} branch using
     * the same orphan-commit-force-push pattern as
     * {@link ReleaseSupport}.
     *
     * @param orgRoot   root of the cloned org repository
     * @param repoUrl   git remote URL for force-push
     * @param log       Maven logger
     * @throws MojoException if publishing fails
     */
    public static void publishToGhPages(File orgRoot, String repoUrl, Log log)
            throws MojoException {
        Path stagingDir = orgRoot.toPath().resolve("target").resolve("staging");
        if (!Files.isDirectory(stagingDir)) {
            throw new MojoException(
                    "Staging directory does not exist: " + stagingDir
                            + ". Site build may have failed.");
        }

        log.info("Publishing org site to gh-pages...");

        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("ike-org-site-publish-");
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create temp directory for publish", e);
        }

        try {
            File tempRoot = tempDir.toFile();

            ReleaseSupport.exec(tempRoot, log,
                    "git", "init");
            ReleaseSupport.exec(tempRoot, log,
                    "git", "checkout", "--orphan", GH_PAGES_BRANCH);

            try {
                ReleaseSupport.copyDirectory(stagingDir, tempDir);
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy staging dir: " + e.getMessage(), e);
            }

            ReleaseSupport.exec(tempRoot, log,
                    "git", "add", "-A");
            ReleaseSupport.exec(tempRoot, log,
                    "git", "commit", "-m", "site: publish org landing page");

            ReleaseSupport.exec(tempRoot, log,
                    "git", "push", "--force", repoUrl,
                    GH_PAGES_BRANCH + ":" + GH_PAGES_BRANCH);

            log.info("Org site published to gh-pages");
        } finally {
            ReleaseSupport.deleteDirectory(tempDir);
        }
    }

    // ── Repository operations ────────────────────────────────────────

    /**
     * Shallow-clone the org site repository into a temporary directory.
     *
     * @param repoUrl  git remote URL
     * @param branch   branch to clone
     * @param log      Maven logger
     * @return the cloned directory
     * @throws MojoException if cloning fails
     */
    public static File cloneOrgRepo(String repoUrl, String branch, Log log)
            throws MojoException {
        Path tempDir;
        try {
            tempDir = Files.createTempDirectory("ike-org-site-");
        } catch (IOException e) {
            throw new MojoException(
                    "Could not create temp directory for clone", e);
        }

        log.info("Cloning " + repoUrl + " (" + branch + ")...");
        ReleaseSupport.exec(tempDir.getParent().toFile(), log,
                "git", "clone", "--depth", "1", "--branch", branch,
                repoUrl, tempDir.getFileName().toString());

        return tempDir.toFile();
    }

    /**
     * Commit all changes in the org repo and push to the remote.
     *
     * @param orgRoot root of the cloned org repository
     * @param message commit message
     * @param branch  branch to push
     * @param log     Maven logger
     * @throws MojoException if commit or push fails
     */
    public static void commitAndPush(File orgRoot, String message,
                                      String branch, Log log)
            throws MojoException {
        ReleaseSupport.exec(orgRoot, log, "git", "add", "-A");

        // Check if there are changes to commit
        String status = ReleaseSupport.execCapture(orgRoot,
                "git", "status", "--porcelain");
        if (status.isBlank()) {
            log.info("No changes to commit (fragment unchanged)");
            return;
        }

        ReleaseSupport.exec(orgRoot, log,
                "git", "commit", "-m", message);
        ReleaseSupport.exec(orgRoot, log,
                "git", "push", "origin", branch);
    }

    /**
     * Run the full registration workflow on a two-repo org-site
     * layout (#367):
     *
     * <ol>
     *   <li>Clone the SOURCE repo ({@code srcRepoUrl}) — has the
     *       Maven pom and src/site/ tree.</li>
     *   <li>Write the per-project fragment into
     *       {@code projects/&lt;artifactId&gt;.adoc}.</li>
     *   <li>Regenerate the master index from all fragments.</li>
     *   <li>Render the index AsciiDoc to XHTML (for doxia).</li>
     *   <li>Run {@code mvn site} to produce {@code target/site/}.</li>
     *   <li>Commit + push the source repo so the fragment + index
     *       are persisted.</li>
     *   <li>Publish {@code target/site/} to the PUBLISH repo
     *       ({@code pubRepoUrl}) by cloning, wiping non-Git
     *       contents, copying the build output, committing, and
     *       pushing.</li>
     * </ol>
     *
     * <p>Pre-#367 this method assumed a single repo for both roles
     * and ran {@code mvn site} inside a repo that had no pom —
     * which silently failed every release that tried to auto-update
     * the landing page. The two-repo split mirrors the README in
     * IKE-Network.github.io and the manual flow operators have been
     * using all along.
     *
     * @param callerGitRoot git root of the calling project
     * @param log           Maven logger
     * @param srcRepoUrl    git URL of the source repo (has pom)
     * @param pubRepoUrl    git URL of the publish repo (rendered HTML)
     * @param srcBranch     branch in the source repo
     * @param pubBranch     branch in the publish repo
     * @param artifactId    Maven artifact ID
     * @param name          human-readable project name
     * @param description   project description
     * @param version       release version
     * @param siteUrl       public site URL
     * @param githubUrl     GitHub repository URL
     * @param modules       reactor module names
     * @throws MojoException if any step fails
     */
    public static void registerProject(File callerGitRoot, Log log,
                                        String srcRepoUrl, String pubRepoUrl,
                                        String srcBranch, String pubBranch,
                                        String artifactId, String name,
                                        String description, String version,
                                        String siteUrl, String githubUrl,
                                        List<String> modules)
            throws MojoException {
        File srcRoot = cloneOrgRepo(srcRepoUrl, srcBranch, log);
        try {
            writeFragment(srcRoot, artifactId, name, description,
                    version, siteUrl, githubUrl, modules);
            regenerateIndex(srcRoot);
            regenerateSiteXml(srcRoot);
            // No renderToXhtml here: the source pom has
            // asciidoctor-parser-doxia-module wired into
            // maven-site-plugin's plugin dependencies, so buildSite's
            // `mvn site site:stage` renders src/site/asciidoc/*.adoc
            // natively. A separate pre-render to
            // target/generated-site/xhtml/index.xhtml produces a
            // second source for index.html and trips the site plugin's
            // duplicate-output check.
            buildSite(srcRoot, log);
            // Link inline goal references to their latest goal docs in the
            // rendered org-landing HTML before it ships to the publish repo
            // (IKE-Network/ike-issues#783).
            GoalLinkRewriter.rewriteSiteHtml(
                    srcRoot.toPath().resolve("target").resolve("site"), log);
            commitAndPush(srcRoot,
                    "site: register " + artifactId + " " + version,
                    srcBranch, log);
            publishToPubRepo(srcRoot, pubRepoUrl, pubBranch,
                    artifactId, version, log);
        } finally {
            ReleaseSupport.deleteDirectory(srcRoot.toPath());
        }
    }

    /**
     * Run the full deregistration workflow against the two-repo
     * org-site layout (#367 — mirror of
     * {@link #registerProject(File, Log, String, String, String,
     *        String, String, String, String, String, String,
     *        String, List)}).
     *
     * @param log        Maven logger
     * @param srcRepoUrl git URL of the source repo (has pom)
     * @param pubRepoUrl git URL of the publish repo (rendered HTML)
     * @param srcBranch  branch in the source repo
     * @param pubBranch  branch in the publish repo
     * @param artifactId artifact ID to deregister
     * @throws MojoException if any step fails
     */
    public static void deregisterProject(Log log,
                                          String srcRepoUrl, String pubRepoUrl,
                                          String srcBranch, String pubBranch,
                                          String artifactId)
            throws MojoException {
        File srcRoot = cloneOrgRepo(srcRepoUrl, srcBranch, log);
        try {
            deleteFragment(srcRoot, artifactId);
            regenerateIndex(srcRoot);
            regenerateSiteXml(srcRoot);
            // No renderToXhtml — see registerProject for the rationale.
            buildSite(srcRoot, log);
            commitAndPush(srcRoot,
                    "site: deregister " + artifactId,
                    srcBranch, log);
            publishToPubRepo(srcRoot, pubRepoUrl, pubBranch,
                    artifactId, "(deregister)", log);
        } finally {
            ReleaseSupport.deleteDirectory(srcRoot.toPath());
        }
    }

    /**
     * Publish the {@code target/site/} contents from a freshly-
     * built source repo to a separate publish repo.
     *
     * <p>Clones the publish repo at {@code pubBranch}, wipes every
     * file/directory except {@code .git/}, copies the entire
     * {@code target/site/} tree into the clone root, commits with a
     * descriptive message, and pushes. Because the source build
     * emits {@code .nojekyll} and {@code CNAME} (from
     * {@code src/site/resources/}), those land naturally — no
     * special preservation needed.
     *
     * <p>Replaces the pre-#367 {@code publishToGhPages} flow, which
     * pushed to a {@code gh-pages} branch of the SAME repo as the
     * source. The new flow correctly addresses the IKE-Network
     * org-site layout where source and publish live in different
     * repos and the publish repo serves from {@code main}.
     *
     * @param srcRoot     the source repo with {@code target/site/}
     *                    built
     * @param pubRepoUrl  git URL of the publish repo
     * @param pubBranch   branch to push to (typically {@code main})
     * @param artifactId  for the commit message
     * @param version     for the commit message
     * @param log         Maven logger
     * @throws MojoException if any step fails
     */
    public static void publishToPubRepo(File srcRoot, String pubRepoUrl,
                                         String pubBranch,
                                         String artifactId, String version,
                                         Log log)
            throws MojoException {
        Path siteDir = srcRoot.toPath()
                .resolve("target").resolve("site");
        if (!Files.isDirectory(siteDir)) {
            throw new MojoException(
                    "Source repo's target/site/ does not exist: "
                            + siteDir + ". buildSite likely failed.");
        }

        File pubRoot = cloneOrgRepo(pubRepoUrl, pubBranch, log);
        try {
            // Wipe everything in pubRoot except .git/. The source
            // build's target/site/ contains .nojekyll and CNAME
            // (from src/site/resources/), so we don't need to
            // preserve anything pre-existing.
            try (Stream<Path> entries = Files.list(pubRoot.toPath())) {
                for (Path entry : entries.toList()) {
                    if (entry.getFileName().toString().equals(".git")) {
                        continue;
                    }
                    if (Files.isDirectory(entry)) {
                        ReleaseSupport.deleteDirectory(entry);
                    } else {
                        Files.delete(entry);
                    }
                }
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to clear publish repo before copy: "
                                + e.getMessage(), e);
            }

            try {
                ReleaseSupport.copyDirectory(siteDir, pubRoot.toPath());
            } catch (IOException e) {
                throw new MojoException(
                        "Failed to copy target/site to publish repo: "
                                + e.getMessage(), e);
            }

            // Commit + push. If the rendered output happens to be
            // byte-identical to the previous publish (rare —
            // outputTimestamp moves), `git commit` will fail with
            // 'nothing to commit'; tolerate that as a no-op
            // publish.
            ReleaseSupport.exec(pubRoot, log, "git", "add", "-A");
            String status;
            try {
                status = ReleaseSupport.execCapture(pubRoot,
                        "git", "status", "--porcelain").trim();
            } catch (MojoException e) {
                status = "";
            }
            if (status.isEmpty()) {
                log.info("  Publish repo unchanged after rebuild — "
                        + "nothing to push.");
                return;
            }
            String message = "site: publish "
                    + artifactId + " " + version
                    + " (auto-register, #367)";
            ReleaseSupport.exec(pubRoot, log,
                    "git", "commit", "-m", message);
            ReleaseSupport.exec(pubRoot, log,
                    "git", "push", "origin", pubBranch);
            log.info("  Org-site updated: "
                    + artifactId + " " + version);
        } finally {
            ReleaseSupport.deleteDirectory(pubRoot.toPath());
        }
    }

    // ── Internal helpers ─────────────────────────────────────────────

    /**
     * Resolve Maven executable. Prefers {@code mvnw} in the repo,
     * falls back to system {@code mvn}.
     */
    private static File resolveMaven(File repoRoot, Log log)
            throws MojoException {
        File mvnw = new File(repoRoot, "mvnw");
        if (mvnw.isFile() && mvnw.canExecute()) {
            return mvnw;
        }
        // Fall back to system Maven
        try {
            String path = ReleaseSupport.execCapture(repoRoot, "which", "mvn");
            return new File(path.trim());
        } catch (Exception e) {
            throw new MojoException(
                    "No Maven wrapper or system 'mvn' found", e);
        }
    }
}