Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}.
*
* <br>
*
* 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;
Expand Down Expand Up @@ -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:
* <ul>
* <li>{@code xref:component:module:page.adoc#anchor[text]} and {@code xref:module:page.adoc#anchor[text]}</li>
* <li>{@code xref:../module/page.adoc#anchor[text]}</li>
* </ul>
* 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;
}
}
Original file line number Diff line number Diff line change
@@ -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});
}
}
Loading