From 152130b613b65954ef0da9a3327e7deddb8c830a Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Sat, 3 Oct 2026 10:44:20 +0300 Subject: [PATCH] [#317] Turn cross-guide xrefs into site links in asciidoc-to-pdf Plain Asciidoctor knows neither the other guides nor the other Antora components, so the PDF goal left every xref that leaves the current guide as a dead link (opendj:install-guide:index.pdf, ../connectors-guide/x.pdf). The goal now renders from a copy of the sources (target/asciidoc/pdf-source) in which such xrefs become links to the page on the documentation site: - xref:component:module:page.adoc#a[] and xref:module:page.adoc#a[] - xref:../module/page.adoc#a[] A relative xref into the current guide becomes a same-guide xref; other xrefs are left as they are. The antora goal keeps reading the original sources. New goal parameters: siteUrl (default https://doc.openidentityplatform.org) and antoraComponent (default: projectName in lower case). Fixes #317 --- .../doc/maven/AsciidocToPdfMojo.java | 121 +++++++++++++++++- .../doc/maven/AsciidocToPdfMojoTest.java | 112 ++++++++++++++++ 2 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 commons/doc-maven-plugin/src/test/java/org/openidentityplatform/doc/maven/AsciidocToPdfMojoTest.java 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: + * + * 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}); + } +}