From 3d90590698490dba769e5d9e22b911160d89fc97 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 1 Oct 2026 15:51:03 +0300 Subject: [PATCH 1/3] [#1147] Fail the doc build when a page leaves an AsciiDoc attribute unresolved Render every page of the pre-processed doc sources on its own, the way Antora publishes each chapter, with attribute-missing=warn and an extension that reports an attribute reference in a verbatim block without subs="attributes". Either warning fails the build in the new check-attribute-references execution. Escape the two literal placeholders the check found on master: {options} in the windows-service synopsis and {path} in the password policy chapter. --- opendj-doc-generated-ref/pom.xml | 46 ++++++++++++ .../asciidoc/admin-guide/chap-pwd-policy.adoc | 4 +- .../man-pages/man-windows-service.adoc | 4 +- .../literal-attribute-references.rb | 74 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb diff --git a/opendj-doc-generated-ref/pom.xml b/opendj-doc-generated-ref/pom.xml index 4bcfdf20e4..a00929445e 100644 --- a/opendj-doc-generated-ref/pom.xml +++ b/opendj-doc-generated-ref/pom.xml @@ -603,6 +603,52 @@ ${project.build.directory}/asciidoc/man-pages + + + check-attribute-references + verify + + process-asciidoc + + + + ${project.basedir}/src/main/resources/asciidoc/extensions/nested-open-block.rb + ${project.basedir}/src/main/resources/asciidoc/extensions/literal-attribute-references.rb + + html5 + ${project.build.directory}/asciidoc/source + ${project.build.directory}/asciidoc/attribute-check + true + true + true + + warn + ${project.build.directory}/asciidoc/source + + + + WARN + attribute + + + + 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 20df95971f..0c7e404f53 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 @@ $ ldappasswordmodify \ --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/man-pages/man-windows-service.adoc b/opendj-doc-generated-ref/src/main/asciidoc/man-pages/man-windows-service.adoc index 2a74099801..15645548dc 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/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 0000000000..9f3ce6016a --- /dev/null +++ b/opendj-doc-generated-ref/src/main/resources/asciidoc/extensions/literal-attribute-references.rb @@ -0,0 +1,74 @@ +# 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 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, +# or when an attribute entry anywhere under the directory named by the +# literal-attribute-sources attribute sets it. The second catches a page that neither +# defines the attribute nor substitutes it. Braces around any other name are meant +# literally - {SSHA} password values, {cn} in a MakeLDIF template - and are left alone. +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. + def self.names_in dir + @names_by_dir[dir] ||= Dir.glob(File.join dir, '**', '*.adoc').each_with_object(Set.new) do |path, names| + File.foreach(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 replay the + # entries of the body in document order, as the converter does, and reset them again + # for the converter afterwards. An AsciiDoc table cell is a document of its own. + documents = [] + document.find_by traverse_documents: true do |block| + documents << block if block.context == :document + block.document.playback_attributes block.attributes + check block, names if Asciidoctor::Block === block && + (block.content_model == :verbatim || block.content_model == :raw) && !(block.subs.include? :attributes) + false + end + documents.each(&:restore_attributes) + nil + end + + def check block, names + attributes = block.document.attributes + block.lines.join(Asciidoctor::LF).scan(ReferenceRx) do |escaped, name| + next if escaped || !((attributes.key? name.downcase) || (names.include? name.downcase)) + logger.warn message_with_context %(attribute {#{name}} is published as literal text: the #{block.context} block does not substitute attributes, add subs="attributes"), source_location: block.source_location + end + end +end + +Asciidoctor::Extensions.register do + tree_processor LiteralAttributeReferences +end From 8dba192e8a4078c77927ffc21e631fa8c293e4d0 Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Thu, 1 Oct 2026 16:55:38 +0300 Subject: [PATCH 2/3] [#1147] Check literal table cells and escaped or intrinsic references, test the check with its pom configuration, and keep the special characters of the attribute listings - literal-attribute-references.rb also checks a literal table cell (l|, cols="1l"), which takes no subs and gets its own advice; reports \{name} and intrinsic names such as {nbsp} in a block without attribute subs; advises subs="+attributes", which keeps the block's default subs; and globs the sources with the directory as base, so braces in its path no longer empty the set of names. - LiteralAttributeReferencesTest renders pages with the configuration of the check-attribute-references execution, read from the pom, and checks which fail the build. The pom module binds testCompile and test for it; the plugin and the test share asciidoctorj ${asciidoctorj.version}. - The 16 blocks of the guides with subs="attributes" take subs="+attributes": the replacing form had dropped specialcharacters, so the plugin guide published "(build , revision )" as raw tags, which a browser hides. - The pom comment gives the rerun command with the profiles it needs off Linux and names the Maven project properties as a blind spot of the check. --- opendj-doc-generated-ref/pom.xml | 67 ++++++- .../asciidoc/admin-guide/chap-monitoring.adoc | 6 +- .../admin-guide/chap-troubleshooting.adoc | 2 +- .../asciidoc/install-guide/chap-install.adoc | 16 +- .../install-guide/chap-uninstall.adoc | 2 +- .../asciidoc/install-guide/chap-upgrade.adoc | 4 +- .../chap-writing-plugins.adoc | 2 +- .../literal-attribute-references.rb | 44 +++-- .../doc/LiteralAttributeReferencesTest.java | 172 ++++++++++++++++++ 9 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java diff --git a/opendj-doc-generated-ref/pom.xml b/opendj-doc-generated-ref/pom.xml index a00929445e..93d2248bdb 100644 --- a/opendj-doc-generated-ref/pom.xml +++ b/opendj-doc-generated-ref/pom.xml @@ -34,8 +34,24 @@ UTF-8 ${project.version} ${project.version} + + 2.5.11 + + + org.asciidoctor + asciidoctorj + ${asciidoctorj.version} + test + + + org.openidentityplatform.commons + build-tools + test + + + ${project.groupId}.${project.artifactId} @@ -356,6 +372,34 @@ + + + + org.apache.maven.plugins + maven-compiler-plugin + + + compile-extension-tests + test-compile + + testCompile + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + test-extensions + test + + test + + + + @@ -611,14 +655,20 @@ 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". The HTML output is thrown away. + 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 -pl opendj-doc-generated-ref + 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. 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}. + 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. --> check-attribute-references @@ -650,6 +700,13 @@ + + + org.asciidoctor + asciidoctorj + ${asciidoctorj.version} + + 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 435b5e97d7..11734a1d56 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 @@ SNMP is not enabled by default. SNMP-based monitoring depends on OpenDMK, which 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 @@ $ dsconfig \ ---- 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 @@ OpenDJ comes with two commands for monitoring server processes and tasks. The `s 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-troubleshooting.adoc b/opendj-doc-generated-ref/src/main/asciidoc/admin-guide/chap-troubleshooting.adoc index 81342776ea..c63e47c646 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 @@ OpenDJ maintains historical information about changes in order to bring replicas 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 106f82998a..f5e9f1e275 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 @@ If you do not start the server during installation, you can use the `/path/to/op . 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 @@ On Debian and related Linux distributions such as Ubuntu, you can install OpenDJ . Install the OpenDJ directory server package. Use `apt-get install ./.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 @@ $ sudo systemctl start opendj . (Optional) Check OpenDJ directory server status: + -[source, console, subs="attributes"] +[source, console, subs="+attributes"] ---- $ systemctl is-active opendj active @@ -525,7 +525,7 @@ Password: . Install the OpenDJ directory server package. Use `dnf install ./.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 @@ To see basic server configuration status and configuration you can launch . (Optional) Check OpenDJ directory server status: + -[source, console, subs="attributes"] +[source, console, subs="+attributes"] ---- # systemctl is-active opendj active @@ -623,7 +623,7 @@ The package is not code-signed, so Windows SmartScreen or User Account Control m * 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 @@ If you have multiple servers to install, consider scripting creation of the prop . 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 @@ END_OF_COMMAND_INPUT . 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 af448ffb9b..1cff64e268 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 @@ C:\path\to\opendj\bat> windows-service.bat --disableService . 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 7cca685d73..180e9b993d 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 @@ C:\path\to\opendj\bat> windows-service.bat --enableService ==== 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 @@ A server started with `start-ds.bat` rather than as a service is refused in the . 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/server-dev-guide/chap-writing-plugins.adoc b/opendj-doc-generated-ref/src/main/asciidoc/server-dev-guide/chap-writing-plugins.adoc index 0e40bd462d..59498dcd35 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 @@ $ mvn install . 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 index 9f3ce6016a..5bad0aa468 100644 --- 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 @@ -15,16 +15,19 @@ # 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, +# 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 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. +# 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, -# or when an attribute entry anywhere under the directory named by the -# literal-attribute-sources attribute sets it. The second catches a page that neither -# defines the attribute nor substitutes it. Braces around any other name are meant -# literally - {SSHA} password values, {cn} in a MakeLDIF template - and are left alone. +# 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. 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 @@ -35,10 +38,11 @@ class LiteralAttributeReferences < Asciidoctor::Extensions::TreeProcessor @names_by_dir = {} - # The names that an attribute entry sets in some .adoc file under 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(File.join dir, '**', '*.adoc').each_with_object(Set.new) do |path, names| - File.foreach(path, encoding: 'UTF-8') {|line| names << $1.downcase if EntryRx =~ line } + @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 @@ -52,19 +56,29 @@ def process document document.find_by traverse_documents: true do |block| documents << block if block.context == :document block.document.playback_attributes block.attributes - check block, names if Asciidoctor::Block === block && + if Asciidoctor::Table::Cell === block + # The text of a literal cell only escapes special characters, so its braces stay. + check block, block.text, 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), names, %(#{block.context} block), 'add subs="+attributes"' + end false end documents.each(&:restore_attributes) nil end - def check block, names + def check block, text, names, what, advice attributes = block.document.attributes - block.lines.join(Asciidoctor::LF).scan(ReferenceRx) do |escaped, name| - next if escaped || !((attributes.key? name.downcase) || (names.include? name.downcase)) - logger.warn message_with_context %(attribute {#{name}} is published as literal text: the #{block.context} block does not substitute attributes, add subs="attributes"), source_location: block.source_location + 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 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 0000000000..2be5279f88 --- /dev/null +++ b/opendj-doc-generated-ref/src/test/java/org/openidentityplatform/opendj/doc/LiteralAttributeReferencesTest.java @@ -0,0 +1,172 @@ +/* + * 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"; + + private final List records = new ArrayList<>(); + private final Map 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("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)); + + 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(); + + 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 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

{v}

\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" }, + { "tool {undefinedthing}\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" }, + // An attribute unset in the body is no longer one where the listing stands. + { "= Title\n:v: 1\n\n== Section\n:v!:\n\n----\nx-{v}\n----\n" }, + }; + } + + @Test(dataProvider = "passingPages") + public void pagePassesTheBuild(final String page) { + assertThat(failures(page)).as(page).isEmpty(); + } +} From bf06c2bdf5091bd46b80824711be2e565744068c Mon Sep 17 00:00:00 2001 From: Valera V Harseko Date: Fri, 2 Oct 2026 11:55:43 +0300 Subject: [PATCH 3/3] [#1147] Replay the body's attribute entries on a copy, count a page's own entries in the test as the build does, and keep the test fixture under target The extension played the body's attribute entries back on the document and restored the attributes afterwards. That left the compat mode of the last compat-mode entry to the converter: a header :compat-mode: unset later in the body failed the build on `{cn}`, and a missing reference before a body :compat-mode: lost its warning. The entries are now replayed on a copy of each document's attributes, so the document stays as the converter expects it and nothing needs restoring. In the build the check renders the directory it collects the attribute entries from, so a page's own entries always count, even where the page has unset the attribute. The test now keeps such a page among its sources and expects it to fail, as the build does, and asserts that sourceDirectory and literal-attribute-sources stay the same directory. New rows pin the copy (a reference before a body unset, both compat-mode cases) and the unset of a built-in attribute that no entry sets. The fixture directory moves from java.io.tmpdir to the module's target directory. The pom comment names the partials directory as rendered twice and as a possible false positive. --- opendj-doc-generated-ref/pom.xml | 13 +++++-- .../literal-attribute-references.rb | 34 +++++++++++-------- .../doc/LiteralAttributeReferencesTest.java | 22 ++++++++++-- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/opendj-doc-generated-ref/pom.xml b/opendj-doc-generated-ref/pom.xml index 93d2248bdb..c2d30009c1 100644 --- a/opendj-doc-generated-ref/pom.xml +++ b/opendj-doc-generated-ref/pom.xml @@ -651,9 +651,10 @@ 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, 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; + 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 @@ -669,6 +670,12 @@ 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. --> check-attribute-references 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 index 5bad0aa468..6b35c96161 100644 --- 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 @@ -24,10 +24,12 @@ # 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. 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. +# 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 @@ -49,29 +51,31 @@ def self.names_in dir 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 replay the - # entries of the body in document order, as the converter does, and reset them again - # for the converter afterwards. An AsciiDoc table cell is a document of its own. - documents = [] + # 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| - documents << block if block.context == :document - block.document.playback_attributes block.attributes + 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, names, 'literal table cell', 'use an a| cell with a listing that has subs="+attributes"' if block.content_model == :verbatim + 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), names, %(#{block.context} block), 'add subs="+attributes"' + check block, (block.lines.join Asciidoctor::LF), attributes, names, %(#{block.context} block), 'add subs="+attributes"' end false end - documents.each(&:restore_attributes) nil end - def check block, text, names, what, advice - attributes = block.document.attributes + 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) 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 index 2be5279f88..5bdb0976c3 100644 --- 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 @@ -55,6 +55,8 @@ @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 records = new ArrayList<>(); private final Map attributes = new LinkedHashMap<>(); @@ -69,9 +71,12 @@ 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("attribute-check-{1}[1]-"); + 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( @@ -79,6 +84,10 @@ public void readExecution() throws Exception { 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); @@ -141,7 +150,11 @@ public Object[][] failingPages() { { "= 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" }, }; } @@ -160,8 +173,11 @@ public Object[][] passingPages() { // Braces that are no attribute anywhere are meant literally. { "----\nuserPassword: {SSHA}abc\ncn: {cn}\n----\n" }, { "|===\nl|{SSHA}abc\n|===\n" }, - // An attribute unset in the body is no longer one where the listing stands. - { "= Title\n:v: 1\n\n== Section\n:v!:\n\n----\nx-{v}\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" }, }; }