Published
  • Jul 6, 2021
  • 6 MIN
Author
Category Tech
Topics
Share
Tips for Writing Antora Macros

Tips for Writing Antora Macros

Selection and migration

After a few years of deliberation on migrating to a docs-as-code setup, the team ran a proof of concept and narrowed down the options to Docusaurus, a Markdown static site generator, and Antora, which uses AsciiDoc as the markup language.

Antora and AsciiDoc won because of their native features, such as include directives and the ability to fetch content from multiple Git repositories. The decision was also influenced by our aim to work closely with our developers. We wanted to align the documentation to their typical workflow to encourage them to contribute to our technical content.

To make a long story short, the migration involved these steps:

  1. Converting the back-end content into Markdown and then converting the Markdown into AsciiDoc using pandoc

  2. Setting up the initial Antora structure for our documentation site, keeping it as similar as possible to our old site for ease of transition

  3. Setting up Algolia to crawl and index our site for search

  4. Customizing the Antora UI to match our company palette

  5. Writing Antora macros

Why write a macro?

Though Antora provides a substantial amount of functionality for our technical writers and developers out of the box, we wanted to create extensions to improve our documentation even further. The Antora macros have come in useful and we'll look at two simple examples here.

The challenge with using a static site is dynamic content. In Magnolia's case, modules have different release cycles and it wouldn't be worth to manually track individual module versions. In addition, it would be prone to errors. Instead, we wanted to automatically fetch and display this information to the user.

Another use case for macros is our Java documentation. The documentation links to Java documentation in multiple locations. A class's documentation depends, among other things, on the class’s module, as well as its version. The version changes over time and the class could be moved or renamed. We chose to create a macro to keep this information up-to-date automatically.

The above examples are simple, but I had to spend a lot of time researching how to get started. Despite getting great help, I almost gave up on the task, because this topic is not documented at all, which is why I want to explain it today! Let's dive into it.

Picking an extension type

The Asciidoctor.js documentation lists the following extension types:

  • Block Macro Processor

  • Inline Macro Processor

  • Block Processor

  • Include Processor

  • Preprocessor

  • Postprocessor

  • Treeprocessor

  • Docinfo Processor

We'll take a look at two, the inline macro processor and the postprocessor. I hope that these examples help you to implement any of the extensions.

Exploring Reusable Web Components

This blog explores the 3 main technologies of WebComponenets: HTML templates, shadow DOMs, and custom elements. To learn more, read the blog and check out our code on Git.

Writing an inline macro processor

My first success was getting an inline macro processor to work, thanks to an example I found in a Fedora documentation pull request.

This is what the complicated syntax is doing:

  • The package extension is registered, parsing package:asciidoctor when encountered in the document.

  • The above line is rendered into a link, i.e. https://apps.fedoraproject.org/packages/asciidoctor, allowing the override of the root URL with the page property url-package-url-format at document level.

Luckily, I noticed that it was now possible to use an easier syntax.

To find out in which JAR a class is in, I implemented the javadoc macro, which queries Magnolia’s Nexus instance when a class is defined like this:

javadoc:info.magnolia.templating.functions.TemplatingFunctions

Using the Nexus response, I was able to assemble the link to the class’s Java documentation resulting in this macro structure:

Java

const request = require('sync-request')
function initInlineManMacro ({ file }) {
  return function () {
    this.process((parent, target, attrs) => {
      // …
    })
  }
}
function register (registry, context) {
  registry.inlineMacro('javadoc', initInlineManMacro(context))
}
module.exports.register = register

This script deliberately depends on sync-request, which might seem counterintuitive in modern, async JavaScript. But that’s because I've found experimentally that AsciiDoc renders the content and does not come back to it. Hence, for the response to be ready when the macro gets called, the request has to be blocking.

target evaluates to info.magnolia.templating.functions.TemplatingFunctions.

Let’s now implement the actual process method.

Because of our security requirements, the macro has to ensure that credentials are supplied. If they are not supplied, the macro assumes that we are in a development environment and generates a dummy link only:

Java

const attributes = Opal.hash2(['window'], { window: '_blank' })
if (!process.env.NEXUS_USERNAME || !process.env.NEXUS_PASSWORD) {
  console.log('Environment variables NEXUS_USERNAME and/or NEXUS_PASSWORD not present, not looking up Javadoc links.')
  return this.createInline(parent, 'anchor', shortenedClassName, { type: 'link', target: '#', attributes })
}

Let’s now send a request to Nexus:

Java

// blocking REST call to nexus
var res = request('GET', 'https://nexus.magnolia-cms.com/service/local/lucene/search', {
  qs: {
    cn: target
  },
  headers: {
    Accept: 'application/json', // force nexus to produce JSON rather than XML
    Authorization: 'Basic ' + Buffer.from(process.env.NEXUS_USERNAME + ':' + process.env.NEXUS_PASSWORD).toString('base64')
  }
})

We can now wrap it up by getting the correct values from the object we received and build the URL. I’ll spare you the details, but if you are interested, please take a look at the files at the end of this blog.

Java

console.log('Nexus search done for: ' + target)
// all results will return the same groupId, artifactId and latestRelease
// so we can just pick the first one
const nexusResult = JSON.parse(res.getBody('utf8')).data[0]
const groupId = nexusResult.groupId
const artifactId = nexusResult.artifactId
const version = nexusResult.latestRelease
let url = 'https://nexus.magnolia-cms.com'
…
url += '.html'
return this.createInline(parent, 'anchor', shortenedClassName, { type: 'link', target: url, attributes })

This is how the page looks:

And this is the HTML code:

This page only lists the most commonly used functions. See <a href="https://nexus.magnolia-cms.com/service/local/repositories/magnolia.public.releases/archive/info/magnolia/magnolia-templating/6.2.9/magnolia-templating-6.2.9-javadoc.jar/!/info/magnolia/templating/functions/TemplatingFunctions.html" target="_blank" rel="noopener">TemplatingFunctions</a> for the complete list.

We could have gone even further by adding a parameter to the macro, for example:

javadoc:info.magnolia.rest.ui.field.JsonMultiFieldDefinition[isEnterprise=true]

With this addition, attrs.isEnterprise could be evaluated in the processing code. If its value was positive, we could build the final URL using the magnolia.enterprise.releases repository rather than magnolia.public.releases.

Writing a postprocessor

Now that we are familiar with the basics, let’s build another macro. The flow will be different this time. Rather than converting code into HTML, we’ll hook into the AsciiDoc rendering process to add an attribute to the page.

We want the macro to fetch the latest module version, when our technical writers declare the following Maven coordinates in an AsciiDoc document:

:group-id: info.magnolia.imaging

:artifact-id: magnolia-imaging

The macro shall query the Nexus and define the following variable:

:modules-version

This variable can then be referenced in the document to display the latest module version.

To proceed, we have to choose the correct extension type. A preprocessor would update the content before AsciiDoc does anything with it. Instead, we need to read the page properties first. Therefore, we need a postprocessor that runs after the rendering process.

Java

const request = require('sync-request')
function moduleVersionPostprocessor () {
  this.process((doc, out) => {
  })
}
function register (registry) {
  registry.postprocessor(moduleVersionPostprocessor)
}
module.exports.register = register

The REST request implementation and credentials management are similar, but we’ll parse page attributes:

Java

if (doc.getAttribute('group-id') && doc.getAttribute('artifact-id')) {
  const groupId = doc.getAttribute('group-id')
  const artifactId = doc.getAttribute('artifact-id')

This is how we add the new page attribute:

Java

doc.setAttribute('modules-version', version)
console.log('The following version was defined as page attribute: ' + version)

Despite not modifying the page, we must return its content for the rendering pipeline to complete:

Java

return out

This is the final result of the macro:

The power of Antora macros

Neither of these macros was difficult to implement. The code is much simpler than the Java equivalent in our previous solution. This proves how powerful static sites are and that the migration is well worth the effort.

For more information, you can take a look at the entire files:

About the Author

Maxime Michel Site Reliability Engineer at Magnolia

Half developer, half Site Reliability Engineer (SRE), Maxime contributes to Magnolia’s source code and works closely with the wider product development team. He believes that machines can take care of repetitive tasks so that people can focus on more creative and interesting work. In his role at Magnolia, Maxime is responsible for processes and automation to present our users with a simple solution and a great user experience.

Read more

Magnolia Newsletter

Get our newest blog posts, white papers, and event updates right to your inbox.