diff --git a/commons/doc-maven-plugin/src/main/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojo.java b/commons/doc-maven-plugin/src/main/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojo.java
index 4d8dbb560..e4adcaea6 100644
--- a/commons/doc-maven-plugin/src/main/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojo.java
+++ b/commons/doc-maven-plugin/src/main/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojo.java
@@ -12,26 +12,34 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2024-2026 3A Systems LLC.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.openidentityplatform.doc.maven;
+import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
import org.asciidoctor.Asciidoctor;
import org.asciidoctor.Options;
import org.asciidoctor.ast.Document;
import java.io.File;
+import java.io.IOException;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
+import java.util.Locale;
import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import static org.twdata.maven.mojoexecutor.MojoExecutor.artifactId;
import static org.twdata.maven.mojoexecutor.MojoExecutor.configuration;
@@ -55,14 +63,41 @@ public AsciidocToPdfMojo() {
skipDirectories.add("attachments");
}
+ /**
+ * Documentation site that xrefs leaving the rendered guide point to.
+ */
+ @Parameter(property = "siteUrl", defaultValue = "https://doc.openidentityplatform.org")
+ private String siteUrl;
+
+ /**
+ * Antora component of this project on the documentation site, such as {@code openidm}.
+ *
+ *
+ *
+ * Default: {@code projectName} in lower case.
+ */
+ @Parameter(property = "antoraComponent")
+ private String antoraComponent;
+
public File getPdfOutputDirectory() {
return new File(buildDirectory, "/pdf");
}
+ public File getPdfSourceDirectory() {
+ return new File(buildDirectory, "/pdf-source");
+ }
+
@Override
public void execute() throws MojoExecutionException, MojoFailureException {
String pdfPath = getPdfOutputDirectory().getPath();
- for(File docDir : getAsciidocBuildSourceDirectory().listFiles()) {
+ String component = antoraComponent != null && !antoraComponent.isEmpty()
+ ? antoraComponent : projectName.toLowerCase(Locale.ROOT);
+ try {
+ preparePdfSource(getAsciidocBuildSourceDirectory(), getPdfSourceDirectory(), siteUrl, component);
+ } catch (IOException e) {
+ throw new MojoExecutionException("error preparing PDF sources", e);
+ }
+ for(File docDir : getPdfSourceDirectory().listFiles()) {
String document = FilenameUtils.getBaseName(docDir.toString());
if(skipDirectories.contains(document)) {
continue;
@@ -110,4 +145,88 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
}
+
+ /**
+ * Copies the sources to {@code target} and rewrites there the xrefs that leave a guide,
+ * so that the sources the {@code antora} goal reads stay as they are.
+ */
+ static void preparePdfSource(File source, File target, String siteUrl, String component) throws IOException {
+ if (target.exists()) {
+ FileUtils.deleteDirectory(target);
+ }
+ FileUtils.copyDirectory(source, target);
+ for (File adocFile : FileUtils.listFiles(target, new String[] {"adoc"}, true)) {
+ String module = adocFile.getParentFile().getName();
+ String adoc = FileUtils.readFileToString(adocFile, StandardCharsets.UTF_8);
+ String converted = convertXrefsForPdf(adoc, siteUrl, component, module);
+ if (!converted.equals(adoc)) {
+ FileUtils.writeStringToFile(adocFile, converted, StandardCharsets.UTF_8);
+ }
+ }
+ }
+
+ private static final Pattern XREF_PATTERN = Pattern.compile("xref:([^\\[\\s]+)\\[");
+ private static final Pattern RESOURCE_ID_PATTERN = Pattern.compile("(?:([^:/@$]+):)?([^:/@$]*):([^:@$]+)");
+
+ /**
+ * Plain Asciidoctor knows neither the other guides nor the other Antora components, so it
+ * leaves an xref that leaves the current guide as a dead link in the PDF. Turns such xrefs
+ * into links to the page on the documentation site:
+ *
+ * - {@code xref:component:module:page.adoc#anchor[text]} and {@code xref:module:page.adoc#anchor[text]}
+ * - {@code xref:../module/page.adoc#anchor[text]}
+ *
+ * A relative xref into {@code currentModule} becomes a same-guide xref. Other xrefs are left as they are.
+ */
+ static String convertXrefsForPdf(String adoc, String siteUrl, String component, String currentModule) {
+ String site = siteUrl.replaceAll("/+$", "");
+ Matcher m = XREF_PATTERN.matcher(adoc);
+ StringBuilder builder = new StringBuilder();
+ int i = 0;
+ while (m.find()) {
+ builder.append(adoc, i, m.start());
+ builder.append(convertXrefForPdf(m.group(1), site, component, currentModule)).append("[");
+ i = m.end();
+ }
+ builder.append(adoc.substring(i));
+ return builder.toString();
+ }
+
+ private static String convertXrefForPdf(String target, String site, String component, String currentModule) {
+ int hash = target.indexOf('#');
+ String path = hash < 0 ? target : target.substring(0, hash);
+ String fragment = hash < 0 ? "" : target.substring(hash);
+
+ String targetComponent;
+ String module;
+ String page;
+ if (path.startsWith("../")) {
+ String modulePath = path.substring("../".length()).replaceAll("(^|/)(\\./)+", "$1");
+ int slash = modulePath.indexOf('/');
+ if (slash <= 0) {
+ return "xref:" + target;
+ }
+ targetComponent = component;
+ module = modulePath.substring(0, slash);
+ page = modulePath.substring(slash + 1);
+ if (module.equals(currentModule)) {
+ return "xref:" + page + fragment;
+ }
+ } else {
+ Matcher id = RESOURCE_ID_PATTERN.matcher(path);
+ if (!id.matches()) {
+ return "xref:" + target;
+ }
+ targetComponent = id.group(1) != null ? id.group(1) : component;
+ module = id.group(2).isEmpty() ? "ROOT" : id.group(2);
+ page = id.group(3);
+ }
+ if (page.isEmpty()) {
+ return "xref:" + target;
+ }
+ page = page.replaceAll("\\.adoc$", "");
+ // Antora publishes the pages of the ROOT module directly under the component
+ String modulePrefix = module.equals("ROOT") ? "" : module + "/";
+ return "link:" + site + "/" + targetComponent + "/" + modulePrefix + page + ".html" + fragment;
+ }
}
diff --git a/commons/doc-maven-plugin/src/test/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojoTest.java b/commons/doc-maven-plugin/src/test/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojoTest.java
new file mode 100644
index 000000000..ca7a21140
--- /dev/null
+++ b/commons/doc-maven-plugin/src/test/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojoTest.java
@@ -0,0 +1,112 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+
+package org.openidentityplatform.doc.maven;
+
+import org.apache.commons.io.FileUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class AsciidocToPdfMojoTest {
+
+ private static final String SITE = "https://doc.openidentityplatform.org";
+
+ @Rule
+ public TemporaryFolder tmp = new TemporaryFolder();
+
+ private static String convert(String adoc) {
+ return AsciidocToPdfMojo.convertXrefsForPdf(adoc, SITE, "openidm", "getting-started");
+ }
+
+ @Test
+ public void testCrossComponentXref() {
+ assertThat(convert("see xref:opendj:install-guide:index.adoc[Install Guide]."))
+ .isEqualTo("see link:" + SITE + "/opendj/install-guide/index.html[Install Guide].");
+ assertThat(convert("xref:opendj:admin-guide:chap-replication.adoc#read-ecl-as-regular-user[ECL]"))
+ .isEqualTo("link:" + SITE
+ + "/opendj/admin-guide/chap-replication.html#read-ecl-as-regular-user[ECL]");
+ }
+
+ @Test
+ public void testCrossModuleXrefInSameComponent() {
+ assertThat(convert("xref:install-guide:chap-install.adoc#x[Install]"))
+ .isEqualTo("link:" + SITE + "/openidm/install-guide/chap-install.html#x[Install]");
+ }
+
+ @Test
+ public void testRootModuleHasNoModuleSegment() {
+ assertThat(convert("xref:opendj::index.adoc[OpenDJ] xref:openam:ROOT:index.adoc[OpenAM]"))
+ .isEqualTo("link:" + SITE + "/opendj/index.html[OpenDJ] link:" + SITE + "/openam/index.html[OpenAM]");
+ }
+
+ @Test
+ public void testCrossGuideRelativeXref() {
+ assertThat(convert("xref:../connectors-guide/chap-ldap.adoc#ldap-connector[LDAP]"))
+ .isEqualTo("link:" + SITE + "/openidm/connectors-guide/chap-ldap.html#ldap-connector[LDAP]");
+ assertThat(convert("xref:../integrators-guide/index.adoc[Integrator's Guide]"))
+ .isEqualTo("link:" + SITE + "/openidm/integrators-guide/index.html[Integrator's Guide]");
+ assertThat(convert("xref:../reference/./ch02.adoc#anchor[Ref]"))
+ .isEqualTo("link:" + SITE + "/openidm/reference/ch02.html#anchor[Ref]");
+ }
+
+ @Test
+ public void testRelativeXrefIntoCurrentGuideStaysInternal() {
+ assertThat(convert("xref:../getting-started/chap-overview.adoc#a[Overview]"))
+ .isEqualTo("xref:chap-overview.adoc#a[Overview]");
+ }
+
+ @Test
+ public void testSameGuideXrefsAreUnchanged() {
+ String adoc = "xref:#anchor[A] xref:chap-x.adoc#anchor[B] xref:./chap-y.adoc[C] "
+ + "xref:ROOT:attachment$file.zip[D]";
+ assertThat(convert(adoc)).isEqualTo(adoc);
+ }
+
+ @Test
+ public void testSiteUrlTrailingSlashIsIgnored() {
+ assertThat(AsciidocToPdfMojo.convertXrefsForPdf("xref:../a/b.adoc[B]", SITE + "/", "openidm", "c"))
+ .isEqualTo("link:" + SITE + "/openidm/a/b.html[B]");
+ }
+
+ @Test
+ public void testPreparePdfSourceRewritesCopyOnly() throws Exception {
+ File source = tmp.newFolder("source");
+ File guide = new File(source, "getting-started");
+ File chapter = new File(guide, "chap-where-to-go.adoc");
+ String original = "xref:opendj:install-guide:index.adoc[OpenDJ] xref:#local[Local]\n";
+ FileUtils.writeStringToFile(chapter, original, StandardCharsets.UTF_8);
+ File partial = new File(source, "partials/note.adoc");
+ FileUtils.writeStringToFile(partial, "xref:../getting-started/chap-x.adoc[X]\n", StandardCharsets.UTF_8);
+ File image = new File(source, "images/logo.png");
+ FileUtils.writeByteArrayToFile(image, new byte[] {1, 2, 3});
+
+ File target = new File(tmp.getRoot(), "pdf-source");
+ AsciidocToPdfMojo.preparePdfSource(source, target, SITE, "openidm");
+
+ assertThat(FileUtils.readFileToString(chapter, StandardCharsets.UTF_8)).isEqualTo(original);
+ assertThat(new File(target, "getting-started/chap-where-to-go.adoc"))
+ .hasContent("link:" + SITE + "/opendj/install-guide/index.html[OpenDJ] xref:#local[Local]");
+ assertThat(new File(target, "partials/note.adoc"))
+ .hasContent("link:" + SITE + "/openidm/getting-started/chap-x.html[X]");
+ assertThat(new File(target, "images/logo.png")).hasBinaryContent(new byte[] {1, 2, 3});
+ }
+}