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
77 changes: 77 additions & 0 deletions commons/doc-maven-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,82 @@ The following example shows the full configuration to use the 2.1.3 version.
If you need to create your own shared content artifact,
see the `forgerock-doc-common-content` project as an example.

# Documentation site versions in AsciiDoc PDF links

The `asciidoc-to-pdf` goal converts cross-guide and cross-component xrefs to
links on `siteUrl` (default `https://doc.openidentityplatform.org`). By default,
these links use the unversioned current documentation. To build a PDF for
archived documentation, configure the published site version paths explicitly:

```xml
<configuration>
<antoraComponent>openam</antoraComponent>
<docVersion>15.2</docVersion>
<componentDocVersions>
<opendj>4.10</opendj>
</componentDocVersions>
</configuration>
```

`docVersion` is also available as the Maven property `-DdocVersion=15.2`; a
`docVersion` project property, including one inherited from a parent POM, sets it too.
Omit it for current documentation. `componentDocVersions` is a Maven map keyed
by Antora component name; configure it in the POM. Each value is the exact
version directory published by the site, not a release tag or Maven version.
For example, the site's `versions-from-tags.js` maps `OpenAM-15.2.2` to `15.2`
and `OpenDJ-4.10.2` to `4.10`; its branch sources have no version directory.
Neither `projectVersion` nor `releaseVersion` determines the site path.

For `xref:reference:chap-auth-modules.adoc#data-store-module-ref[Data Store]`,
the configuration above produces
`https://doc.openidentityplatform.org/openam/15.2/reference/chap-auth-modules.html#data-store-module-ref`.
For `xref:opendj::index.adoc[OpenDJ]`, it produces
`https://doc.openidentityplatform.org/opendj/4.10/index.html`.
The `ROOT` module has no module directory. Relative cross-guide xrefs also use
the current component's configured version; fragments and same-guide xrefs are
preserved. Only the PDF source copy is rewritten; Antora sources are unchanged.

Version selection, in order:

1. An explicit Antora version in an xref that names a module, such as
`xref:4.10@opendj:install-guide:index.adoc[Install]`.
2. An entry for the target component in `componentDocVersions`.
3. `docVersion`, only when the target is this project's component.
4. The unversioned current documentation.

Rule 3 also applies when the xref explicitly names this project's component,
such as `xref:openam:ROOT:index.adoc[OpenAM]` in an OpenAM PDF. With
`docVersion=15.2`, the PDF deliberately links to `/openam/15.2/index.html`.
Antora instead resolves a component-qualified xref without an explicit version
to the latest version of that component, so the HTML link can differ.

An empty map entry or Antora's versionless token `_` (`xref:_@opendj::index.adoc[Current]`)
selects the unversioned site. The empty version form `xref:@opendj::index.adoc[Current]`
is not a valid Antora resource ID and is not converted. A component without an entry never inherits
another component's version.

Version-only resource IDs such as `xref:15.1@page.adoc[Older]` are left unchanged.
To generate a site link in the PDF, name the target module explicitly, for example
`xref:15.1@how-to:page.adoc[Older]`. This also avoids depending on the including
page's module when the xref occurs in a shared partial.

Rebuilding requires a doc-maven-plugin release that supports `docVersion` and
`componentDocVersions`; these options are unavailable in 3.2.2 and earlier.
Release POMs that pin the plugin version, such as OpenDJ 4.10.2 and 5.2.0 through
`${commons.version}`, must select a supporting plugin version for the rebuild.
Passing `-DdocVersion` alone does not upgrade the plugin; an older plugin ignores
the property and keeps unversioned links.

When a release moves from current to archived documentation, first publish and
verify its historical site paths, then rebuild its PDF from the immutable
release sources using the corresponding `docVersion` and independently chosen
component versions. For a POM with the AsciiDoc preprocessing and PDF goals
bound to the site lifecycle, use `mvn -DdocVersion=15.2 site`; otherwise run the
project's normal PDF build with that property. Replace the archived PDF with
the rebuilt artifact. Previously distributed PDFs retain their original links;
this converter cannot update them. Do not select a historical version path
before that version is published on the site.

* * *

The contents of this file are subject to the terms of the Common Development and
Expand All @@ -757,3 +833,4 @@ Header, with the fields enclosed by brackets [] replaced by your own identifying
information: "Portions copyright [year] [name of copyright owner]".

Copyright 2012-2015 ForgeRock AS.
Portions copyright 2026 3A Systems, LLC.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2024-2026 3A Systems LLC.
* Portions Copyright 2026 3A Systems, LLC.
* Copyright 2024-2026 3A Systems, LLC.
*/

package org.openidentityplatform.doc.maven;
Expand All @@ -35,8 +34,10 @@
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
Expand Down Expand Up @@ -79,6 +80,21 @@ public AsciidocToPdfMojo() {
@Parameter(property = "antoraComponent")
private String antoraComponent;

/**
* Site version path for this component, such as {@code 15.2}. Empty means the
* unversioned current documentation. This is independent of the Maven project version.
*/
@Parameter(property = "docVersion")
private String docVersion;

/**
* Site version paths by Antora component name. Entries override {@code docVersion}
* for that component; other components never inherit this project's version.
* An empty entry selects the unversioned current documentation.
*/
@Parameter
private Map<String, String> componentDocVersions = Collections.emptyMap();

public File getPdfOutputDirectory() {
return new File(buildDirectory, "/pdf");
}
Expand All @@ -93,7 +109,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
String component = antoraComponent != null && !antoraComponent.isEmpty()
? antoraComponent : projectName.toLowerCase(Locale.ROOT);
try {
preparePdfSource(getAsciidocBuildSourceDirectory(), getPdfSourceDirectory(), siteUrl, component);
preparePdfSource(getAsciidocBuildSourceDirectory(), getPdfSourceDirectory(), siteUrl, component,
docVersion, componentDocVersions);
} catch (IOException e) {
throw new MojoExecutionException("error preparing PDF sources", e);
}
Expand Down Expand Up @@ -151,22 +168,28 @@ public void execute() throws MojoExecutionException, MojoFailureException {
* 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 {
preparePdfSource(source, target, siteUrl, component, null, Collections.emptyMap());
}

static void preparePdfSource(File source, File target, String siteUrl, String component,
String docVersion, Map<String, String> componentDocVersions) 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);
String converted = convertXrefsForPdf(adoc, siteUrl, component, module, docVersion, componentDocVersions);
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("(?:([^:/@$]+):)?([^:/@$]*):([^:@$]+)");
private static final Pattern RESOURCE_ID_PATTERN =
Pattern.compile("(?:([^:/@$]+)@)?(?:([^:/@$]+):)?([^:/@$]*):([^:@$]+)");

/**
* Plain Asciidoctor knows neither the other guides nor the other Antora components, so it
Expand All @@ -176,27 +199,38 @@ static void preparePdfSource(File source, File target, String siteUrl, String co
* <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>
* Explicit {@code version@component:module:page.adoc} resource IDs override configured
* component versions; an explicit {@code _} version selects the unversioned site.
* Resource IDs without a module coordinate, such as {@code version@page.adoc}, are left unchanged.
* 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) {
return convertXrefsForPdf(adoc, siteUrl, component, currentModule, null, Collections.emptyMap());
}

static String convertXrefsForPdf(String adoc, String siteUrl, String component, String currentModule,
String docVersion, Map<String, String> componentDocVersions) {
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("[");
builder.append(convertXrefForPdf(m.group(1), site, component, currentModule,
docVersion, componentDocVersions)).append("[");
i = m.end();
}
builder.append(adoc.substring(i));
return builder.toString();
}

private static String convertXrefForPdf(String target, String site, String component, String currentModule) {
private static String convertXrefForPdf(String target, String site, String component, String currentModule,
String docVersion, Map<String, String> componentDocVersions) {
int hash = target.indexOf('#');
String path = hash < 0 ? target : target.substring(0, hash);
String fragment = hash < 0 ? "" : target.substring(hash);

String explicitVersion = null;
String targetComponent;
String module;
String page;
Expand All @@ -217,16 +251,28 @@ private static String convertXrefForPdf(String target, String site, String compo
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);
explicitVersion = id.group(1);
// Antora uses _ to explicitly select the versionless component version.
if ("_".equals(explicitVersion)) {
explicitVersion = "";
}
targetComponent = id.group(2) != null ? id.group(2) : component;
module = id.group(3).isEmpty() ? "ROOT" : id.group(3);
page = id.group(4);
}
if (page.isEmpty()) {
return "xref:" + target;
}
String version = explicitVersion;
if (version == null) {
version = componentDocVersions.containsKey(targetComponent)
? componentDocVersions.get(targetComponent)
: targetComponent.equals(component) ? docVersion : null;
}
String versionPrefix = version == null || version.isEmpty() ? "" : version + "/";
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;
return "link:" + site + "/" + targetComponent + "/" + versionPrefix + modulePrefix + page + ".html" + fragment;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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.apache.maven.execution.MavenSession;
import org.apache.maven.model.io.xpp3.MavenXpp3Reader;
import org.apache.maven.plugin.testing.AbstractMojoTestCase;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.xml.Xpp3Dom;

import java.io.File;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

import static org.assertj.core.api.Assertions.assertThat;

public class AsciidocToPdfMojoConfigurationTest extends AbstractMojoTestCase {

private File buildDirectory;

@Override
protected void setUp() throws Exception {
super.setUp();
buildDirectory = Files.createTempDirectory("pdf-configuration").toFile();
}

@Override
protected void tearDown() throws Exception {
try {
FileUtils.deleteDirectory(buildDirectory);
} finally {
super.tearDown();
}
}

public void testEmptyPomEntryOverridesProjectVersion() throws Exception {
MavenProject project = readProject();
AsciidocToPdfMojo mojo = (AsciidocToPdfMojo) lookupConfiguredMojo(project, "asciidoc-to-pdf");
assertPreparedLinks(mojo, "");
}

public void testPomVersionsReachPreparedPdfSource() throws Exception {
MavenProject project = readProject();
Xpp3Dom configuration = (Xpp3Dom) project.getBuildPlugins().get(0).getConfiguration();
Xpp3Dom versions = configuration.getChild("componentDocVersions");
versions.removeChild(versions.getChild("openam"));
AsciidocToPdfMojo mojo = (AsciidocToPdfMojo) lookupConfiguredMojo(project, "asciidoc-to-pdf");
assertPreparedLinks(mojo, "15.2/");
}

public void testMavenUserPropertySelectsHistoricalVersion() throws Exception {
MavenProject project = readProject();
Xpp3Dom configuration = (Xpp3Dom) project.getBuildPlugins().get(0).getConfiguration();
configuration.removeChild(configuration.getChild("docVersion"));
Xpp3Dom versions = configuration.getChild("componentDocVersions");
versions.removeChild(versions.getChild("openam"));
MavenSession session = newMavenSession(project);
session.getUserProperties().setProperty("docVersion", "15.2");
AsciidocToPdfMojo mojo = (AsciidocToPdfMojo) lookupConfiguredMojo(session,
newMojoExecution("asciidoc-to-pdf"));
assertPreparedLinks(mojo, "15.2/");
}

public void testComponentDocVersionsIsADescriptorParameter() throws Exception {
assertTrue(newMojoExecution("asciidoc-to-pdf").getMojoDescriptor()
.getParameterMap().containsKey("componentDocVersions"));
}

public void testAbsentMapKeepsOtherComponentsUnversioned() throws Exception {
MavenProject project = readProject();
Xpp3Dom configuration = (Xpp3Dom) project.getBuildPlugins().get(0).getConfiguration();
configuration.removeChild(configuration.getChild("componentDocVersions"));
AsciidocToPdfMojo mojo = (AsciidocToPdfMojo) lookupConfiguredMojo(project, "asciidoc-to-pdf");
FileUtils.writeStringToFile(new File(mojo.getAsciidocBuildSourceDirectory(), "how-to/page.adoc"),
"xref:reference:page.adoc[A] xref:opendj::index.adoc[B]", StandardCharsets.UTF_8);

mojo.execute();

assertThat(new File(mojo.getPdfSourceDirectory(), "how-to/page.adoc")).hasContent(
"link:https://doc.openidentityplatform.org/openam/15.2/reference/page.html[A] "
+ "link:https://doc.openidentityplatform.org/opendj/index.html[B]");
}

private MavenProject readProject() throws Exception {
File pom = getTestFile("src/test/resources/asciidoc-to-pdf/pom.xml");
try (Reader reader = Files.newBufferedReader(pom.toPath(), StandardCharsets.UTF_8)) {
MavenProject project = new MavenProject(new MavenXpp3Reader().read(reader));
project.setFile(pom);
project.getBuild().setDirectory(buildDirectory.getAbsolutePath());
return project;
}
}

private void assertPreparedLinks(AsciidocToPdfMojo mojo, String versionPrefix) throws Exception {
FileUtils.writeStringToFile(new File(mojo.getAsciidocBuildSourceDirectory(), "how-to/page.adoc"),
"xref:reference:page.adoc#a[A] xref:opendj::index.adoc[B]", StandardCharsets.UTF_8);
mojo.execute();
assertThat(new File(mojo.getPdfSourceDirectory(), "how-to/page.adoc")).hasContent(
"link:https://doc.openidentityplatform.org/openam/" + versionPrefix + "reference/page.html#a[A] "
+ "link:https://doc.openidentityplatform.org/opendj/4.10/index.html[B]");
}
}
Loading
Loading