From 4870ff34abcd5f0f8e9262956a581ca0160fe4be Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 02 Oct 2026 17:21:40 +0000
Subject: [PATCH] [#1147] Fail the doc build when a page leaves an AsciiDoc attribute unresolved (#1148)
---
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc | 16 +-
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc | 2
opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc | 4
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc | 2
opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc | 2
opendj-doc-generated-ref/pom.xml | 110 +++++++++++++
opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc | 4
opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb | 92 +++++++++++
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc | 6
opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc | 4
opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java | 188 +++++++++++++++++++++++
11 files changed, 410 insertions(+), 20 deletions(-)
diff --git a/opendj-doc-generated-ref/pom.xml b/opendj-doc-generated-ref/pom.xml
index 4bcfdf2..c2d3000 100644
--- a/opendj-doc-generated-ref/pom.xml
+++ b/opendj-doc-generated-ref/pom.xml
@@ -34,8 +34,24 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<docTargetVersion>${project.version}</docTargetVersion>
<sdkDocTargetVersion>${project.version}</sdkDocTargetVersion>
+ <!-- The Asciidoctor of asciidoctor-maven-plugin, which the tests of the doc extensions also run on -->
+ <asciidoctorj.version>2.5.11</asciidoctorj.version>
</properties>
+ <dependencies>
+ <dependency>
+ <groupId>org.asciidoctor</groupId>
+ <artifactId>asciidoctorj</artifactId>
+ <version>${asciidoctorj.version}</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.openidentityplatform.commons</groupId>
+ <artifactId>build-tools</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
<build><finalName>${project.groupId}.${project.artifactId}</finalName>
<plugins>
@@ -356,6 +372,34 @@
</execution>
</executions>
</plugin>
+
+ <!-- A pom module compiles and runs no tests by itself: these test the Asciidoctor extensions -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>compile-extension-tests</id>
+ <phase>test-compile</phase>
+ <goals>
+ <goal>testCompile</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>test-extensions</id>
+ <phase>test</phase>
+ <goals>
+ <goal>test</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
</plugins>
</build>
@@ -603,7 +647,73 @@
<outputDirectory>${project.build.directory}/asciidoc/man-pages</outputDirectory>
</configuration>
</execution>
+ <!--
+ Fail the build when a page would publish an attribute reference as literal
+ text, which neither the PDF below nor the Antora site reports. Every .adoc of
+ the pre-processed sources is rendered on its own, the way Antora renders each
+ chapter as a page (partials aside, see below), so a page that relies on an
+ attribute another chapter defines fails here although the PDF book resolves
+ it. attribute-missing=warn reports a reference to an attribute that is not
+ defined;
+ literal-attribute-references.rb reports one in a verbatim block without
+ subs="+attributes" or in a literal table cell. The HTML output is thrown away.
+ LiteralAttributeReferencesTest renders its pages with the configuration of
+ this execution.
+
+ The plugin stops at the first page that fails, so fix it and run again to see
+ the next one: mvn -Pdistribution-unix,man-pages -pl opendj-doc-generated-ref
+ asciidoctor:process-asciidoc@check-attribute-references, after a build of this
+ module (both profiles activate by themselves only on Linux). Asciidoctor names
+ no file for a missing attribute: it is the page of the "Converted" line that
+ follows the warning. A literal {name} in text is written \{name}.
+
+ Maven project properties are attributes here (product.name is {product-name})
+ but not on the site, so a page that uses one passes this check and still shows
+ the braces there.
+
+ The partials directory is rendered too, although Antora only includes its files
+ and never publishes them as pages: the plugin has no exclusion, and a name that
+ starts with _ would break the includes. So every man page is checked twice, and
+ a partial that relies on an attribute of the chapter that includes it fails
+ here although the site resolves it.
+ -->
+ <execution>
+ <id>check-attribute-references</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>process-asciidoc</goal>
+ </goals>
+ <configuration>
+ <requires>
+ <require>${project.basedir}/src/main/resources/asciidoc/extensions/nested-open-block.rb</require>
+ <require>${project.basedir}/src/main/resources/asciidoc/extensions/literal-attribute-references.rb</require>
+ </requires>
+ <backend>html5</backend>
+ <sourceDirectory>${project.build.directory}/asciidoc/source</sourceDirectory>
+ <outputDirectory>${project.build.directory}/asciidoc/attribute-check</outputDirectory>
+ <preserveDirectories>true</preserveDirectories>
+ <relativeBaseDir>true</relativeBaseDir>
+ <sourcemap>true</sourcemap>
+ <attributes>
+ <attribute-missing>warn</attribute-missing>
+ <literal-attribute-sources>${project.build.directory}/asciidoc/source</literal-attribute-sources>
+ </attributes>
+ <logHandler>
+ <failIf>
+ <severity>WARN</severity>
+ <containsText>attribute</containsText>
+ </failIf>
+ </logHandler>
+ </configuration>
+ </execution>
</executions>
+ <dependencies>
+ <dependency>
+ <groupId>org.asciidoctor</groupId>
+ <artifactId>asciidoctorj</artifactId>
+ <version>${asciidoctorj.version}</version>
+ </dependency>
+ </dependencies>
</plugin>
<!-- Format man pages -->
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc
index 435b5e9..11734a1 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-monitoring.adoc
@@ -79,7 +79,7 @@
To run the OpenDMK installer, use the self-extracting .jar:
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ java -jar ~/Downloads/opendmk-1.0-b02-*.jar
$ cd ~/Downloads/
@@ -150,7 +150,7 @@
----
Use a command such as `snmpwalk` to check that the SNMP listen port works:
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ snmpwalk -v 2c -c OpenDJ@OpenDJ localhost:11161
SNMPv2-SMI::mib-2.66.1.1.1.1 = STRING: "OpenDJ {opendj-version}..."
@@ -248,7 +248,7 @@
The `status` command takes administrative credentials to read the configuration, as does the control panel:
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ status --bindDN "cn=Directory Manager" --bindPassword password
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc
index 20df959..0c7e404 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-pwd-policy.adoc
@@ -12,7 +12,7 @@
information: "Portions copyright [year] [name of copyright owner]".
Copyright 2017 ForgeRock AS.
- Portions Copyright 2024 3A Systems LLC.
+ Portions Copyright 2024-2026 3A Systems LLC.
////
:figure-caption!:
@@ -1009,7 +1009,7 @@
--authzID "u:bjensen" \
--newPassword '!ABcd$%^'
----
-In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath {path}` option to save the result of your interactive session to a file for use in scripts later.
+In the preceding example, the character set of ASCII punctuation, ``!\"#\$%&\'\(\)*+,-./:\;\\<=\>?@[\\]^_\`{\|}~``, is hard to read because of all the escape characters. In practice it can be easier to enter sequences like that by using `dsconfig` in interactive mode, and letting it do the escaping for you. You can also use the `--commandFilePath \{path}` option to save the result of your interactive session to a file for use in scripts later.
An attempt to set an invalid password fails as shown in the following example:
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
index 8134277..c63e47c 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc
@@ -808,7 +808,7 @@
When you cannot resolve a problem yourself, and want to ask for help, clearly identify the problem and how you reproduce it, and also the version of OpenDJ you use to reproduce the problem. The version includes both a version number and also a build time stamp:
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ dsconfig --version
OpenDJ {opendj-version}
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
index 106f829..f5e9f1e 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-install.adoc
@@ -383,7 +383,7 @@
. Run the `status` command, described in xref:../reference/admin-tools-ref.adoc#status-1[status(1)] in the __Reference__, to make sure your OpenDJ server is working as expected as shown in the following example:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ /path/to/opendj/bin/status
@@ -437,7 +437,7 @@
. Install the OpenDJ directory server package. Use `apt-get install ./<file>.deb` (rather than `dpkg -i`) so the required Java runtime dependency (`default-jre-headless`) is resolved and installed automatically:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ sudo apt-get install ./opendj_{opendj-version}-1_all.deb
----
@@ -463,7 +463,7 @@
. (Optional) Check OpenDJ directory server status:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ systemctl is-active opendj
active
@@ -525,7 +525,7 @@
. Install the OpenDJ directory server package. Use `dnf install ./<file>.rpm` (rather than `rpm -i`) so the required Java runtime dependency (`java-headless >= 11`) is resolved and installed automatically:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
# dnf install ./opendj-{opendj-version}-1.noarch.rpm
Post Install - initial install
@@ -552,7 +552,7 @@
. (Optional) Check OpenDJ directory server status:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
# systemctl is-active opendj
active
@@ -623,7 +623,7 @@
* Silent: run the following command (optionally set the installation directory with the `OPENDJ` property):
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\opendj"
----
@@ -701,7 +701,7 @@
. Prepare an installation script:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ cat /net/install/dj/1/setup.sh
#!/bin/sh
@@ -756,7 +756,7 @@
. Run your installation script:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ /net/install/dj/1/setup.sh
Archive: /net/install/dj/opendj-{opendj-version}.zip
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc
index af448ff..1cff64e 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-uninstall.adoc
@@ -176,7 +176,7 @@
. Uninstall the package, either through __Settings > Apps__ (or __Control Panel > Programs and Features__) by selecting OpenDJ and choosing Uninstall, or from the command-line:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
C:\> msiexec /x opendj-{opendj-version}.msi /quiet
----
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc
index 7cca685..180e9b9 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/install-guide/chap-upgrade.adoc
@@ -153,7 +153,7 @@
====
The following example upgrades an OpenDJ 2.6.3 directory server, backing up the current server directory in case the upgrade process fails. In this example, the server properties are updated to use Java 11, and the Local DB backend is migrated to a JE backend:
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ cd /path/to/
$ sed -e "s/default.java-home=.*/default.java-home=\/path\/to\/jdk11/" \
@@ -268,7 +268,7 @@
. Install the newer package (GUI or silent). The installer detects the existing installation — the location recorded in the registry by a previous x64 package, or the default directory of the older 32-bit package (`C:\Program Files (x86)\OpenDJ`) — and installs into the same directory, so your configured instance data (`config`, `db`, `logs`) is kept and only the program files are replaced. If the older server was installed in a custom directory the installer cannot detect, select that directory in the wizard or pass it explicitly on the command line: rather than installing a fresh server into the default directory while emptying the old one, the installer refuses to continue whenever nothing has recorded where the old server lives and the directory it is about to install into holds no OpenDJ server -- which also catches a mistyped directory name. That refusal also covers an old server that really is installed in `C:\Program Files\OpenDJ`, because the 32-bit packages recorded no location at all — and that one case the wizard cannot resolve: choosing the default directory in the wizard leaves the installer with the same values it would have had if you had chosen nothing, so pass `OPENDJ` on the command line instead — it can be given with or without `/quiet`, so a wizard installation takes it just as a silent one does. The installer further refuses to install into a directory other than the one it detected, unless the directory you name holds an OpenDJ server itself (see the note below): it replaces an installation in place and cannot move one, so uninstall the existing server first if you want it somewhere else.
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
C:\> msiexec /i opendj-{opendj-version}.msi /quiet OPENDJ="C:\path\to\opendj"
----
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc b/opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc
index 2a74099..1564554 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc
@@ -13,7 +13,7 @@
information: "Portions Copyright [year] [name of copyright owner]".
Copyright 2015-2016 ForgeRock AS.
- Portions Copyright 2024 3A Systems LLC.
+ Portions Copyright 2024-2026 3A Systems LLC.
////
@@ -28,7 +28,7 @@
windows-service - register OpenDJ as a Windows Service
== Synopsis
-`windows-service` {options}
+`windows-service` \{options}
== Description
This utility can be used to run OpenDJ directory server as a Windows Service.
diff --git a/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc b/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc
index 0e40bd4..59498dc 100644
--- a/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc
+++ b/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc
@@ -170,7 +170,7 @@
. Install the example plugin in OpenDJ directory server:
+
-[source, console, subs="attributes"]
+[source, console, subs="+attributes"]
----
$ cd /path/to/opendj
diff --git a/opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb b/opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb
new file mode 100644
index 0000000..6b35c96
--- /dev/null
+++ b/opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb
@@ -0,0 +1,92 @@
+# 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.
+
+# Warns about an attribute reference that a verbatim block publishes as literal text.
+#
+# A listing, literal or passthrough block replaces {name} only when its subs include
+# attributes (subs="+attributes"). Without them the braces reach the page as they are,
+# and Asciidoctor says nothing: `unzip opendj-{opendj-version}.zip` was published that
+# way. A literal table cell (l|) never substitutes attributes and takes no subs. A
+# reference to an attribute that is not defined at all, outside such a block, is
+# Asciidoctor's own warning once attribute-missing is set to warn.
+#
+# A reference is reported when its name is an attribute at that point of the document,
+# an intrinsic one such as {nbsp}, or one that an attribute entry anywhere under the
+# directory named by the literal-attribute-sources attribute sets. The last catches a
+# page that neither defines the attribute nor substitutes it, and since the directory
+# holds the page itself, it also counts a name that the page has unset by the time the
+# block stands: such braces are written \{name} in a block with subs="+attributes".
+# Braces around any other name are meant literally - {SSHA} password values, {cn} in a
+# MakeLDIF template - and are left alone. Without attribute subs a backslash does not
+# escape the reference, so \{name} is published with its backslash and is reported too.
+require 'set'
+
+class LiteralAttributeReferences < Asciidoctor::Extensions::TreeProcessor
+ include Asciidoctor::Logging
+
+ ReferenceRx = /(\\)?\{(\w[\w-]*)\}/
+ EntryRx = /^:(\w[\w-]*):/
+
+ @names_by_dir = {}
+
+ # The names that an attribute entry sets in some .adoc file under dir. The directory is
+ # the base of the glob, not a part of the pattern, so braces in its path match as such.
+ def self.names_in dir
+ @names_by_dir[dir] ||= Dir.glob('**/*.adoc', base: dir).each_with_object(Set.new) do |path, names|
+ File.foreach((File.join dir, path), encoding: 'UTF-8') {|line| names << $1.downcase if EntryRx =~ line }
+ end
+ end
+
+ def process document
+ dir = document.attr 'literal-attribute-sources'
+ names = dir ? (LiteralAttributeReferences.names_in dir) : Set.new
+ # The parser has already reset the document attributes to the header, so the entries
+ # of the body are replayed in document order, as the converter does, on a copy of
+ # them. The document itself is left as the converter expects it: playing the entries
+ # back on it would also carry the compat mode of the last one over to the converter.
+ # An AsciiDoc table cell is a document of its own.
+ attributes_of = Hash.new {|copies, doc| copies[doc] = doc.attributes.dup }
+ document.find_by traverse_documents: true do |block|
+ attributes = attributes_of[block.document]
+ (block.attributes[:attribute_entries] || []).each do |entry|
+ entry.negate ? (attributes.delete entry.name) : (attributes[entry.name] = entry.value)
+ end
+ if Asciidoctor::Table::Cell === block
+ # The text of a literal cell only escapes special characters, so its braces stay.
+ check block, block.text, attributes, names, 'literal table cell', 'use an a| cell with a listing that has subs="+attributes"' if block.content_model == :verbatim
+ elsif Asciidoctor::Block === block &&
+ (block.content_model == :verbatim || block.content_model == :raw) && !(block.subs.include? :attributes)
+ # subs="attributes" would replace the default subs of the block, so the advice adds to them.
+ check block, (block.lines.join Asciidoctor::LF), attributes, names, %(#{block.context} block), 'add subs="+attributes"'
+ end
+ false
+ end
+ nil
+ end
+
+ def check block, text, attributes, names, what, advice
+ text.scan(ReferenceRx) do |escaped, name|
+ key = name.downcase
+ next unless (attributes.key? key) || (names.include? key) || (Asciidoctor::INTRINSIC_ATTRIBUTES.key? key)
+ message = escaped ?
+ %(\\{#{name}} is published with its backslash: the #{what} does not substitute attributes, #{advice}) :
+ %(attribute {#{name}} is published as literal text: the #{what} does not substitute attributes, #{advice})
+ logger.warn message_with_context message, source_location: block.source_location
+ end
+ end
+end
+
+Asciidoctor::Extensions.register do
+ tree_processor LiteralAttributeReferences
+end
diff --git a/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java b/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java
new file mode 100644
index 0000000..5bdb097
--- /dev/null
+++ b/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.opendj.doc;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathFactory;
+
+import org.asciidoctor.Asciidoctor;
+import org.asciidoctor.Options;
+import org.asciidoctor.SafeMode;
+import org.asciidoctor.log.LogRecord;
+import org.asciidoctor.log.Severity;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * Renders pages with the configuration of the check-attribute-references execution of this module's
+ * pom - its extensions, its attributes and its failIf - and checks which of them would fail the build.
+ * Reading the configuration from the pom keeps the execution itself under test, not only
+ * literal-attribute-references.rb.
+ */
+@Test
+public class LiteralAttributeReferencesTest extends ForgeRockTestCase {
+ private static final String EXECUTION = "check-attribute-references";
+ /** A page that unsets an attribute and then names it in a listing, kept among the sources. */
+ private static final String UNSET_PAGE = "= Title\n:u: 1\n\n== Section\n:u!:\n\n----\nx-{u}\n----\n";
+
+ private final List<LogRecord> records = new ArrayList<>();
+ private final Map<String, Object> attributes = new LinkedHashMap<>();
+ private Asciidoctor asciidoctor;
+ private String backend;
+ private boolean sourcemap;
+ private Severity failSeverity;
+ private String failText;
+
+ @BeforeClass
+ public void readExecution() throws Exception {
+ final File module = new File(System.getProperty("basedir", "."));
+ // The pre-processed sources sit under the build directory. Braces and brackets in its path
+ // must not turn it into a glob pattern.
+ final Path buildDirectory = Files.createTempDirectory(
+ Files.createDirectories(new File(module, "target").toPath()), "attribute-check-{1}[1]-");
+ final Path sources = Files.createDirectories(buildDirectory.resolve("asciidoc/source/other-guide"));
+ Files.write(sources.resolve("chap-other.adoc"), ":elsewhere: 1\n".getBytes(StandardCharsets.UTF_8));
+ // The build renders the pages of the directory it collects the entries from, so a page is among them.
+ Files.write(sources.resolve("chap-unset.adoc"), UNSET_PAGE.getBytes(StandardCharsets.UTF_8));
+
+ final XPath xpath = XPathFactory.newInstance().newXPath();
+ final Element configuration = (Element) xpath.evaluate(
+ "//execution[id='" + EXECUTION + "']/configuration",
+ DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new File(module, "pom.xml")),
+ XPathConstants.NODE);
+ assertThat(configuration).as("configuration of the " + EXECUTION + " execution").isNotNull();
+ // The fixture stands for both directories, which holds only while they are one.
+ assertThat(xpath.evaluate("sourceDirectory", configuration).trim())
+ .as("the check renders the directory whose attribute entries it collects")
+ .isEqualTo(xpath.evaluate("attributes/literal-attribute-sources", configuration).trim());
+
+ asciidoctor = Asciidoctor.Factory.create();
+ asciidoctor.registerLogHandler(records::add);
+ final NodeList requires = (NodeList) xpath.evaluate("requires/require", configuration, XPathConstants.NODESET);
+ for (int i = 0; i < requires.getLength(); i++) {
+ asciidoctor.requireLibrary(requires.item(i).getTextContent().trim()
+ .replace("${project.basedir}", module.getAbsolutePath()));
+ }
+ final NodeList entries = (NodeList) xpath.evaluate("attributes/*", configuration, XPathConstants.NODESET);
+ for (int i = 0; i < entries.getLength(); i++) {
+ final Node entry = entries.item(i);
+ attributes.put(entry.getNodeName(), entry.getTextContent().trim()
+ .replace("${project.build.directory}", buildDirectory.toString()));
+ }
+ backend = xpath.evaluate("backend", configuration);
+ sourcemap = Boolean.parseBoolean(xpath.evaluate("sourcemap", configuration));
+ failSeverity = Severity.valueOf(xpath.evaluate("logHandler/failIf/severity", configuration));
+ failText = xpath.evaluate("logHandler/failIf/containsText", configuration);
+ }
+
+ @AfterClass(alwaysRun = true)
+ public void shutdown() {
+ if (asciidoctor != null) {
+ asciidoctor.shutdown();
+ }
+ }
+
+ /** Returns the messages that make the execution fail the build on this page. */
+ private List<String> failures(final String page) {
+ records.clear();
+ asciidoctor.convert(page, Options.builder()
+ .safe(SafeMode.UNSAFE)
+ .backend(backend)
+ .sourcemap(sourcemap)
+ .attributes(new LinkedHashMap<>(attributes))
+ .toFile(false)
+ .build());
+ return records.stream()
+ .filter(r -> r.getSeverity().ordinal() >= failSeverity.ordinal() && r.getMessage().contains(failText))
+ .map(LogRecord::getMessage)
+ .collect(Collectors.toList());
+ }
+
+ @DataProvider
+ public Object[][] failingPages() {
+ return new Object[][] {
+ { ":v: 1\n\n----\nunzip x-{v}.zip\n----\n",
+ "attribute {v} is published as literal text: the listing block does not substitute attributes, "
+ + "add subs=\"+attributes\"" },
+ { ":v: 1\n\n literal x-{v}\n", "attribute {v} is published as literal text: the literal block" },
+ { ":v: 1\n\n++++\n<p>{v}</p>\n++++\n", "attribute {v} is published as literal text: the pass block" },
+ { ":v: 1\n\n|===\nl|cell x-{v}\n|===\n",
+ "attribute {v} is published as literal text: the literal table cell does not substitute attributes, "
+ + "use an a| cell with a listing that has subs=\"+attributes\"" },
+ { ":v: 1\n\n[cols=\"1l\"]\n|===\n|cell x-{v}\n|===\n", "the literal table cell" },
+ { ":v: 1\n\n|===\na|\n----\nx-{v}\n----\n|===\n", "attribute {v} is published as literal text: the listing" },
+ { ":v: 1\n\n----\nunzip x-\\{v}.zip\n----\n", "\\{v} is published with its backslash: the listing block" },
+ { "----\nPATH{nbsp}x\n----\n", "attribute {nbsp} is published as literal text" },
+ // A body entry counts from where it stands, not only a header one.
+ { "= Title\n\n== Section\n:late: 1\n\n----\nx-{late}\n----\n", "attribute {late} is published as literal text" },
+ // A page that neither defines nor substitutes an attribute another page defines.
+ { "----\nx-{elsewhere}\n----\n", "attribute {elsewhere} is published as literal text" },
+ // An entry of the page itself counts, even where the page has unset the attribute.
+ { UNSET_PAGE, "attribute {u} is published as literal text" },
+ { "tool {undefinedthing}\n", "undefinedthing" },
+ // The converter starts from the compat mode of the header, not from that of the last entry.
+ { "= Title\n\n`{undefinedthing}`\n\n:compat-mode:\n\ny\n", "undefinedthing" },
+ };
+ }
+
+ @Test(dataProvider = "failingPages")
+ public void pageFailsTheBuild(final String page, final String message) {
+ assertThat(failures(page)).as(page).anySatisfy(failure -> assertThat(failure).contains(message));
+ }
+
+ @DataProvider
+ public Object[][] passingPages() {
+ return new Object[][] {
+ { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-{v}.zip\n----\n" },
+ { ":v: 1\n\n[subs=\"+attributes\"]\n----\nunzip x-\\{v}.zip\n----\n" },
+ { ":v: 1\n\nunzip x-{v}.zip and \\{v}\n" },
+ { ":v: 1\n\n|===\na|\n[subs=\"+attributes\"]\n----\nx-{v}\n----\n|===\n" },
+ // Braces that are no attribute anywhere are meant literally.
+ { "----\nuserPassword: {SSHA}abc\ncn: {cn}\n----\n" },
+ { "|===\nl|{SSHA}abc\n|===\n" },
+ // A built-in attribute that no entry sets is no longer one where the body has unset it.
+ { "= Title\n\n== Section\n:figure-caption!:\n\n----\nx-{figure-caption}\n----\n" },
+ // The converter starts again from the header: a reference before a body unset still resolves.
+ { "= Title\n:v: 1\n\nx {v}\n\n:v!:\n\ny\n" },
+ { "= Title\n:compat-mode:\n\n`{cn}`\n\n:compat-mode!:\n\ny\n" },
+ };
+ }
+
+ @Test(dataProvider = "passingPages")
+ public void pagePassesTheBuild(final String page) {
+ assertThat(failures(page)).as(page).isEmpty();
+ }
+}
--
Gitblit v1.10.0