Extension Use Cases

This page provides a catalog of simple examples to showcase how you can enhance the capabilities of Antora through the use of extensions. Each section introduces a different use case and presents the extension code you can build on as a starting point.

You can also reference official extension projects provided by the Antora project to study more complex examples.

Set global AsciiDoc attributes

If you want to define global AsciiDoc attributes that dynamic values, you can do using an extension. The playbook holds the AsciiDoc config object, which itself contains the global AsciiDoc attributes. An extension can listen for the playbookBuilt event and add attributes to this map.

Example 1. set-global-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.on('beforeProcess', ({ siteAsciiDocConfig }) => {
    const buildDate = new Date().toISOString()
    siteAsciiDocConfig.attributes['build-date'] = buildDate
  })
}

The extension could read these values from a file or environment variables as well.

If you need to set AsciiDoc attributes that are scoped to a component version, then you’ll need to listen for the contentClassified event instead. From there, you can access the AsciiDoc attributes form the asciidoc property on a component version object. You can look up a component version by name and version using the getComponentVersion method on the content catalog object. Alternately, you can access component versions from the versions property on each component returned by the getComponents method on the content catalog object.

If you’re troubleshooting your site, you can use an extension to generate a report of AsciiDoc attributes at the site level and those per component verison. When making this report, you have a choice of whether you want to show the AsciiDoc attributes as they would be available to a page (aka compiled) or as defined (aka uncompiled)

You can use the following extension to print all the AsciiDoc attributes compiled for each component version. The extension also prints all the attributes compiled from the playbook, though keep in mind these are integrated into the attributes for each component version.

Example 2. print-compiled-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.once('contentClassified', ({ siteAsciiDocConfig, contentCatalog }) => {
    console.log('site-wide attributes (compiled)')
    console.log(siteAsciiDocConfig.attributes)
    contentCatalog.getComponents().forEach((component) => {
      component.versions.forEach((componentVersion) => {
        console.log(`${componentVersion.version}@${componentVersion.name} attributes (compiled)`)
        if (componentVersion.asciidoc === siteAsciiDocConfig) {
          console.log('same as site-wide attributes')
        } else {
          console.log(componentVersion.asciidoc.attributes)
        }
      })
    })
  })
}

You can use the following extension to print all the AsciiDoc attributes as defined in the playbook and in the antora.yml file for each component version (by origin).

Example 3. print-defined-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.once('contentClassified', ({ playbook, contentCatalog }) => {
    console.log('site-wide attributes (as defined in playbook)')
    console.log(playbook.asciidoc.attributes)
    contentCatalog.getComponents().forEach((component) => {
      component.versions.forEach((componentVersion) => {
        getUniqueOrigins(contentCatalog, componentVersion).forEach((origin) => {
          console.log(`${componentVersion.version}@${componentVersion.name} attributes (as defined in antora.yml)`)
          console.log(origin.descriptor.asciidoc?.attributes || {})
        })
      })
    })
  })
}

function getUniqueOrigins (contentCatalog, componentVersion) {
  return contentCatalog.findBy({ component: componentVersion.name, version: componentVersion.version })
    .reduce((origins, file) => {
      const origin = file.src.origin
      if (origin && !origins.includes(origin)) origins.push(origin)
      return origins
    }, [])
}

You may find it useful to make use of these collections of AsciiDoc attributes when writing other extensions.

Exclude private content sources

If some contributors or CI jobs don’t have permission to the private content sources in the playbook, you can use an extension to filter them out instead of having to modify the playbook file.

This extension runs during the playbookBuilt event. It retrieves the playbook, iterates over the content sources, and removes any content source that it detects as private and thus require authentication. We’ll rely on a convention to communicate to the extension which content source is private. That convention is to use an SSH URL that starts with git@. Antora automatically converts SSH URLs to HTTP URLs, so the use of this syntax merely serves as a hint to users and extensions that the URL is private and is going to request authentication.

Example 4. exclude-private-content-sources-extension.js
module.exports.register = function () {
  this.on('playbookBuilt', function ({ playbook }) {
    playbook.content.sources = playbook.content.sources
      .filter(({ url }) => !url.startsWith('git@'))
    this.updateVariables({ playbook })
  })
}

This extension works because the playbook is mutable until the end of this event, at which point Antora freezes it. The call to this.updateVariables to replace the playbook variable in the generator context is not required, but is used here to express intent and to future proof the extension.

Unpublish flagged pages

If you don’t want a page to ever be published, you can prefix the filename with an underscore (e.g., _hidden.adoc). However, if you only want the page to be unpublished conditionally, then you need to reach for an extension.

When using this extension, any page that sets the page-unpublish page attribute will not be published (meaning it will be unpublished). For example:

= Secret Page
:page-unpublish:

This page will not be published.

You can set the page-unpublish page attribute based on the presence (or absence) of another AsciiDoc attribute, perhaps one set in the playbook or as a CLI option. For example:

= Secret Page
ifndef::include-secret[:page-unpublish:]

This page will not be published.

This extension runs during the documentsConverted event. This is the earliest event that provides access to the AsciiDoc metadata on the virtual file. The extension iterates over all publishable pages in the content catalog and unpublishes any page that sets the page-unpublish attribute. To unpublish the page, the extension removes the out property on the virtual file. If the out property is absent, the page will not be published.

Example 5. page-unpublish-tag-extension.js
module.exports.register = function () {
  this.on('documentsConverted', ({ contentCatalog }) => {
    contentCatalog.getPages((page) => page.out).forEach((page) => {
      if (page.asciidoc?.attributes['page-unpublish'] != null) {
        delete page.out
      }
    })
  })
}

Keep in mind that there may be references to the unpublished page. While they will be resolved by Antora, the target of the reference will not be available, which will result in a 404 response from the web server.

For more fine-grained control over when a page is unpublished, you could write an extension that replaces the convertDocument or convertDocuments functions. Doing so would allow you to unpublish the page before references to it from other pages are resolved so that they appear as warnings.

Report unlisted pages

After you create a new page, it’s easy to forget to add it to the navigation so that the reader can access it. We can use an extension to identify pages which are not in the navigation and report them using the logger.

This extension runs during the navigationBuilt event. It iterates over each component version, retrieves a flattened list of its internal navigation entries, then checks to see if there are any pages that are not in that list, comparing pages by URL. If it finds any such pages, it creates a report of them, optionally adding them to the navigation.

Example 6. unlisted-pages-extension.js
module.exports.register = function ({ config }) {
  const { addToNavigation, unlistedPagesHeading = 'Unlisted Pages' } = config
  const logger = this.getLogger('unlisted-pages-extension')
  this
    .on('navigationBuilt', ({ contentCatalog }) => {
      contentCatalog.getComponents().forEach(({ versions }) => {
        versions.forEach(({ name: component, version, navigation: nav, url: defaultUrl }) => {
          const navEntriesByUrl = getNavEntriesByUrl(nav)
          const unlistedPages = contentCatalog
            .findBy({ component, version, family: 'page' })
            .filter((page) => page.out)
            .reduce((collector, page) => {
              if ((page.pub.url in navEntriesByUrl) || page.pub.url === defaultUrl) return collector
              logger.warn({ file: page.src, source: page.src.origin }, 'detected unlisted page')
              return collector.concat(page)
            }, [])
          if (unlistedPages.length && addToNavigation) {
            nav.push({
              content: unlistedPagesHeading,
              items: unlistedPages.map((page) => {
                return { content: page.asciidoc.navtitle, url: page.pub.url, urlType: 'internal' }
              }),
              root: true,
            })
          }
        })
      })
    })
}

function getNavEntriesByUrl (items = [], accum = {}) {
  items.forEach((item) => {
    if (item.urlType === 'internal') accum[item.url.split('#')[0]] = item
    getNavEntriesByUrl(item.items, accum)
  })
  return accum
}

You can read more about this extension and how to configure it in the Extension Tutorial.

Unpublish unlisted pages

Instead of reporting unlisted pages, you could instead remove those pages from publishing. This is one way you can use the navigation to drive which pages are published.

This extension runs during the navigationBuilt event. It iterates over each component version, retrieves a flattened list of its internal navigation entries, then checks to see if there are any pages that are not in that list, comparing pages by URL. If it finds any such pages, it unpublishes them.

Example 7. unpublish-unlisted-pages-extension.js
module.exports.register = function ({ config }) {
  this
    .on('navigationBuilt', ({ contentCatalog }) => {
      contentCatalog.getComponents().forEach(({ versions }) => {
        versions.forEach(({ name: component, version, navigation: nav, url: defaultUrl }) => {
          const navEntriesByUrl = getNavEntriesByUrl(nav)
          const unlistedPages = contentCatalog
            .findBy({ component, version, family: 'page' })
            .filter((page) => page.out)
            .reduce((collector, page) => {
              if ((page.pub.url in navEntriesByUrl) || page.pub.url === defaultUrl) return collector
              return collector.concat(page)
            }, [])
          if (unlistedPages.length) unlistedPages.forEach((page) => delete page.out)
        })
      })
    })
}

function getNavEntriesByUrl (items = [], accum = {}) {
  items.forEach((item) => {
    if (item.urlType === 'internal') accum[item.url.split('#')[0]] = item
    getNavEntriesByUrl(item.items, accum)
  })
  return accum
}

By removing the out property from the page, it prevents the page from being published, but is still referenceable using an include directive. Alternately, you could choose to remove the page entirely from the content catalog.

List discovered component versions

When you’re setting up your playbook, you may find that Antora is not discovering some of your component versions. Using an extension, it’s possible to list the component versions Antora discovers during content aggregation along with the content sources it took them from.

Example 8. discovered-component-versions-extension.js
module.exports.register = function () {
  this.once('contentAggregated', ({ contentAggregate }) => {
    console.log('Discovered the following component versions')
    contentAggregate.forEach((bucket) => {
      const sources = bucket.origins.map(({ url, refname }) => ({ url, refname }))
      console.log({ name: bucket.name, version: bucket.version, files: bucket.files.length, sources })
    })
  })
}

If an entry is missing, then you know you may need to tune the content source definitions in your playbook.

For more information, you can print the whole bucket entry.

Resolve attribute references in attachments

Files in the attachment family are passed directly through to the output site. Antora does not resolve AsciiDoc attribute references in attachment files. (Asciidoctor, on the other hand, will resolve AsciiDoc attribute references in the attachment’s contents only if the attachment is included in an AsciiDoc page where the attribute substitution is enabled.) You can use an Antora extension to have Antora resolve attribute references in the attachment file before that file is published.

This extension runs during the contentClassified event, which is when attachment files are first identified and classified. It iterates over all attachments and resolves any references to attributes scoped to that attachment’s component version. If any changes were made to the contents of the file, it replaces the contents on the virtual file with the updated value.

Example 9. resolve-attributes-references-in-attachments-extension.js
module.exports.register = function () {
  this.on('contentClassified', ({ contentCatalog }) => {
    const componentVersionTable = contentCatalog.getComponents().reduce((componentMap, component) => {
      componentMap[component.name] = component.versions.reduce((versionMap, componentVersion) => {
        versionMap[componentVersion.version] = componentVersion
        return versionMap
      }, {})
      return componentMap
    }, {})
    contentCatalog.findBy({ family: 'attachment' }).forEach((attachment) => {
      const componentVersion = componentVersionTable[attachment.src.component][attachment.src.version]
      let attributes = componentVersion.asciidoc?.attributes
      if (!attributes) return
      attributes = Object.entries(attributes).reduce((accum, [name, val]) => {
        accum[name] = val && val.endsWith('@') ? val.slice(0, val.length - 1) : val
        return accum
      }, {})
      let modified
      const result = attachment.contents.toString().replace(/\{([\p{Alpha}\d_][\p{Alpha}\d_-]*)\}/gu, (match, name) => {
        if (!(name in attributes)) return match
        modified = true
        let value = attributes[name]
        if (value.endsWith('@')) value = value.slice(0, value.length - 1)
        return value
      })
      if (modified) attachment.contents = Buffer.from(result)
    })
  })
}

This extension is only know to work with text-based attachments. You may need to modify this extension for it to work with binary files.

Convert word processor attachments to PDF

Much like AsciiDoc files (.adoc) are converted to HTML (.html) by Antora, you can do the same with attachments. This extension runs during the contentClassified event, which is when attachment files are first identified and classified. It iterates over all attachments in a word processor format (i.e., .docx, .odt, .fodt) and uses the libreoffcie command (LibreOffice in server mode) to convert each file to PDF.

Example 10. doc-to-pdf-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')
const { posix: path } = ospath
const { execFile } = require('node:child_process')

module.exports.register = function () {
  this.once('contentClassified', async ({ playbook, contentCatalog }) => {
    const docExtnames = { '.docx': true, '.fodt': true, '.odt': true }
    const filesToConvert = contentCatalog.getFiles().filter(({ src }) => src.family === 'attachment' && docExtnames[src.extname])
    if (!filesToConvert.length) return
    const buildDirBase = ospath.join(playbook.dir, 'build/doc-to-pdf')
    const convertArgs = ['--writer', '--convert-to', 'pdf']
    const convertOpts = { cwd: buildDirBase, windowsHide: true }
    try {
      await fsp.mkdir(buildDirBase, { recursive: true })
      await Promise.all(filesToConvert.map((file) => {
        const sourceRelpath = `${file.src.component}-${file.src.module}-${file.out.basename}`
        convertArgs.push(sourceRelpath)
        return fsp.writeFile(ospath.join(buildDirBase, sourceRelpath), file.contents)
      }))
      await new Promise((resolve, reject) => {
        execFile('libreoffice', convertArgs, convertOpts, (err, stderr, stdout) => {
          if (!err) return resolve()
          const splitIdx = stderr.indexOf('Usage: ')
          if (~splitIdx) stderr = stderr.slice(0, splitIdx).trimEnd()
          if (stderr) err.message += stderr
          reject(err)
        })
      })
      await Promise.all(filesToConvert.map((file) => {
        file.out.path = path.join(file.out.dirname, (file.out.basename = file.out.basename.slice(0, -file.src.extname.length) + '.pdf'))
        file.pub.url = file.pub.url.slice(0, -file.src.extname.length) + '.pdf'
        const sourceRelpath = `${file.src.component}-${file.src.module}-${file.out.basename}`
        return fsp.readFile(ospath.join(buildDirBase, sourceRelpath)).then((contents) => (file.contents = contents))
      }))
    } finally {
      await fsp.rm(buildDirBase, { recursive: true, force: true })
    }
  })
}

By converting the files and updating the metadata, it’s possible to reference the source document using the xref macro. That reference will automatically translate to a link to the PDF in the generated site.