Antora Extensions

This content was generated with the assistance of AI and should be verified against the official documentation of each tool before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against Antora 3.2.1 and checked on 2026-10-26. It explains the extension API that lets Node.js code hook into the site generator, lists the generator events in the order they are emitted, builds a small extension that reports unused images for the Pocket Units example, and shows how to enable or disable it from the playbook and the command line.

What an extension is and why it exists

An Antora extension is a Node.js module that exports a register function. Antora calls that function once, early in the run, and the function subscribes listeners to the events the site generator emits while it builds the site. A listener can read or replace the data the generator is working on (the playbook, the content catalog, the UI catalog, the site catalog), add generated files, log problems, or stop the run.

Extensions exist so that behavior that is specific to one site (checks, generated pages, extra files, content rewrites) does not have to be added to Antora itself, and so that it keeps working across Antora upgrades. The official extensions used in this section (Lunr search, Collector, Atlas, PDF) are ordinary extensions of this kind.

Do not confuse them with Asciidoctor extensions. An Antora extension is registered under antora.extensions and listens to generator events. An Asciidoctor extension (a block macro such as Mermaid or Kroki) is registered under asciidoc.extensions and runs inside the AsciiDoc converter. Registering an Asciidoctor extension as an Antora extension does not work: the generator detects a register function that takes a registry parameter and skips it with a warning (Skipping Asciidoctor extension registered as an Antora extension).

The register function

The module must export a function named register (CommonJS module.exports.register). Antora loads the module with require, so a file path or an installed package works.

'use strict'

module.exports.register = function ({ config }) {
  // this is the generator context
  this.on('beforePublish', ({ siteCatalog }) => {
    // use the variables of the event here
  })
}

Antora calls register while it initializes the generator, before the first event. The function is not itself an event, and at that point the playbook is still a raw data model and the built-in generator functions are not yet available. The generator calls it in one of three ways, depending on how it is declared (this behavior comes from the 3.2.1 source of @antora/site-generator):

  • With no parameters, it is called with this set to the generator context.

  • With a destructured parameter such as { config }, it is called with this set to the context and the argument holds config plus the variables that exist at that time (at registration only playbook).

  • With a single named parameter such as register (context), it is called with the context as that argument.

Use the function keyword when you rely on this. An arrow function has no this binding of its own, so write function for register and for any listener that calls this.updateVariables or this.stop. If the exported module has no register function, Antora assumes you only want to load a library (or modify global state) and does nothing more.

The extension context

The context (the value of this) provides these members.

Member Purpose

on(eventName, listener)

Subscribes listener to an event. Listeners of the same event run in registration order, and an async listener is awaited before the next one starts.

once(eventName, listener)

Same as on, but the listener is removed after its first call.

getLogger(name)

Returns an Antora logger (warn, info, error, debug). The name appears in each log record. Use it instead of console so that --log-level, --log-format and --log-failure-level apply to your messages.

getVariables()

Returns a shallow copy of the current variables, for example const { contentCatalog } = this.getVariables().

updateVariables(updates)

Adds new variables or replaces existing ones, for example to pass data to a later listener or to swap in a wrapper (proxy) around a catalog. Replacing a locked variable throws an error that says Cannot update locked variable.

stop(code)

Stops the run. The optional code becomes the process exit code, the contextStopped event is emitted, and Antora then closes the context. It works by throwing, so call it directly in a listener and do not wrap it in try.

require(request)

Loads a module, first relative to the site generator and then through the normal Node.js lookup. Use it to reach Antora internals such as this.require('@antora/asciidoc-loader/util/compute-relative-url-path').

getFunctions() and replaceFunctions(updates)

Return, or replace, the generator functions that implement each stage (for example convertDocuments, buildNavigation or publishFiles). They are available from contextStarted onward. Replacing a function is an advanced technique that changes how a stage works, and a replacement must keep the original’s signature.

Configuration

An extension entry in the playbook can be a string (the require request) or a map. In the map form, every key other than require, enabled, id and order is passed to the extension as config, with snake_case keys converted to camelCase. A key written fail_on_unused in the playbook is read as config.failOnUnused. The extension in this page uses exactly this mapping, and the run shown below confirms it.

Generator events

The generator emits the events below, in this order. Each listener receives an object with the variables that are in scope for the event, or it can call this.getVariables(). A variable is locked once it is considered established: after that the variable cannot be replaced, but the object it refers to can still be modified, with one exception. The playbook is frozen, so its properties cannot change.

# Event Variables in scope When it is emitted and what to use it for

1

register()

config, playbook

Not an event: Antora calls the function so you can subscribe listeners.

2

contextStarted

playbook

After initialization, including registration of extensions and generator functions. Built-in generator functions can be used from here.

3

playbookBuilt

playbook

After the playbook is built. This is the customary place to modify content sources or other playbook data. The playbook is locked afterward.

4

beforeProcess

plus siteAsciiDocConfig, siteCatalog

Before content processing starts. siteAsciiDocConfig is locked afterward.

5

contentAggregated

plus contentAggregate

After the content sources are aggregated from Git. The aggregate holds the raw files and component version data, before classification. Collector generates its content here.

6

componentsRegistered

contentCatalog (under construction)

After component version objects exist, while the catalog is still being built. Added in Antora 3.2. An extension can add files to the files of a component version.

7

uiLoaded

plus uiCatalog

When the UI bundle is loaded.

8

contentClassified

contentCatalog, uiCatalog

After the content is classified into families (page, partial, image, attachment, nav and so on). The catalogs can still be changed here, and they are locked afterward.

9

documentsConverted

same

After every page is converted from AsciiDoc to embeddable HTML.

10

navigationBuilt

plus navigationCatalog

After the navigation trees are built.

11

pagesComposed

same as documentsConverted

After each page is wrapped in the UI layout.

12

redirectsProduced

same

After redirect files for aliases are produced.

13

siteMapped

same

After the sitemap is produced. It is emitted only when site.url is set.

14

beforePublish

same

Last chance to add files to the site catalog, for example a .nojekyll file.

15

sitePublished

plus publications

After the files are published. publications (the output locations) is available only to this event.

16

contextStopped

any variable still in scope

When this.stop() is called.

17

contextClosed

any variable still in scope

When the context closes, whether the run succeeded, failed or was stopped. Listeners should catch their own errors so that every listener is notified.

UI loading runs concurrently with content aggregation and classification. For that reason uiLoaded can be emitted before, between or after contentAggregated and componentsRegistered, and the order of those three events is not guaranteed. All other events keep the order shown. The following timeline summarizes the order for a site build.

timeline title Antora generator events section Startup register() : contextStarted : playbookBuilt section Content and UI beforeProcess : contentAggregated : componentsRegistered : uiLoaded section Processing contentClassified : documentsConverted : navigationBuilt section Output pagesComposed : redirectsProduced : siteMapped : beforePublish section Finish sitePublished : contextStopped : contextClosed

The timeline shows uiLoaded after componentsRegistered, which is what happened in the run below, but that position is not fixed. contextStopped is emitted only when an extension calls stop.

Observing the events

This extension prints each event and the names of the variables in scope. It is a handy way to learn what is available where.

lib/event-logger-extension.js
'use strict'

const EVENTS = [
  'contextStarted', 'playbookBuilt', 'beforeProcess', 'contentAggregated', 'componentsRegistered', 'uiLoaded',
  'contentClassified', 'documentsConverted', 'navigationBuilt', 'pagesComposed', 'redirectsProduced', 'siteMapped',
  'beforePublish', 'sitePublished', 'contextStopped', 'contextClosed',
]

module.exports.register = function () {
  console.log('register() called')
  for (const name of EVENTS) {
    this.on(name, function (vars) {
      console.log(`${name}: ${Object.keys(vars).join(', ')}`)
    })
  }
}

With the extension listed under antora.extensions of the Pocket Units playbook, a build with Antora 3.2.1 printed the following (the contextStopped line is absent because nothing stopped the run):

register() called
contextStarted: playbook
playbookBuilt: playbook
beforeProcess: playbook, siteAsciiDocConfig, siteCatalog
contentAggregated: playbook, siteAsciiDocConfig, siteCatalog, contentAggregate
componentsRegistered: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog
uiLoaded: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
contentClassified: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
documentsConverted: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
navigationBuilt: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog, navigationCatalog
pagesComposed: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
redirectsProduced: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
siteMapped: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
beforePublish: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog
sitePublished: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog, publications
contextClosed: playbook, siteAsciiDocConfig, siteCatalog, contentCatalog, uiCatalog

Notice that contentAggregated does not yet have a contentCatalog: it has the raw contentAggregate, which Antora consumes to build the catalog.

Asynchronous listeners

A listener can be an async function or return a promise. Antora runs the listeners of one event one after another and waits for each promise before it calls the next listener or emits the next event, so an extension can fetch data or run a child process safely. An error thrown by a listener (or a rejected promise) ends the run with a fatal error, except in contextClosed, where Antora ignores errors so that cleanup always completes.

Writing an extension: report unused images

The example reports every image in the content that no converted page references. In the Pocket Units example, the v2.0 branch has three images in modules/ROOT/images, and getting-started.adoc uses two of them:

.Conversion flow
image::conversion-flow.svg[Conversion flow,width=200]

image::unit-table.svg[Unit table]

The third image, orphan-banner.svg, is not referenced anywhere. The right moment to find out is documentsConverted: all pages have been converted, so their HTML contains the final img elements, and the catalog still holds every image file. A page’s contents is a Buffer with the embeddable HTML, and each image file in the catalog has family: 'image' and a src.basename.

lib/unused-images-extension.js
'use strict'

module.exports.register = function ({ config }) {
  const logger = this.getLogger('unused-images-extension')
  const failOnUnused = config.failOnUnused === true

  this.once('documentsConverted', ({ contentCatalog }) => {
    // Collect the file name of every image referenced by a converted page
    const referenced = new Set()
    const imgRx = /<img [^>]*?src="([^"]+)"/g
    for (const page of contentCatalog.getPages()) {
      if (!page.contents) continue
      for (const [, src] of page.contents.toString().matchAll(imgRx)) {
        referenced.add(decodeURIComponent(src.split(/[?#]/)[0].split('/').pop()))
      }
    }
    // Compare against the image files of every component version
    const unused = contentCatalog
      .findBy({ family: 'image' })
      .filter((file) => !referenced.has(file.src.basename))
    for (const file of unused) {
      logger.warn('Unused image: %s', file.src.path)
    }
    logger.info('%d of %d images are not referenced', unused.length, contentCatalog.findBy({ family: 'image' }).length)
    if (unused.length && failOnUnused) this.stop(1)
  })
}

The code makes these choices:

  • once is enough because documentsConverted is emitted a single time per run; on would behave the same.

  • The listener is an arrow function because it only needs the destructured variables. The this.stop(1) call works because an arrow function defined inside register inherits this from the enclosing function.

  • The logger is created from the extension name, so each record carries the name unused-images-extension.

  • The fail_on_unused option turns warnings into a failed run. this.stop(1) sets the exit code to 1 and prevents the site from being published.

  • Matching is by file name only. Two images with the same name in different modules or components count as one.

The extension is listed in the playbook with an id, so it can be switched on and off by that name:

antora-playbook.yml
antora:
  extensions:
  - id: unused-images
    require: ./lib/unused-images-extension.js
    fail_on_unused: false
site:
  title: Pocket Units Docs
  url: http://localhost:5252
  start_page: pocket-units::index.adoc
content:
  sources:
  - url: ./src/pocket-units
    branches: v2.0
  - url: ./src/shared
    branches: main
ui:
  bundle:
    url: https://example.com/ui-bundle.zip
    snapshot: true
output:
  dir: build/site

Run it:

npx antora generate antora-playbook.yml
{"level":"warn","time":1793008572895,"name":"unused-images-extension","msg":"Unused image: modules/ROOT/images/orphan-banner.svg"}

The run used the Default UI bundle built locally instead of the placeholder URL shown in the playbook above. Logging at info level (--log-level=info) also prints the summary line 1 of 3 images are not referenced. The Antora logger writes JSON when the output is not a terminal; --log-format=pretty gives a human-readable form.

Setting fail_on_unused: true in the entry makes the same run exit with status 1 after printing the warning, and the site is not published, because stop ends the run before sitePublished.

Limits of this approach: an image embedded as inline SVG, or through any other construct that does not leave an img element in the converted page, is reported as unused, and an image referenced only by a UI template is outside the content catalog and is never considered. Treat the report as a list of candidates.

Enabling and disabling extensions

In the playbook

Add each extension to antora.extensions. The entry is either the require request as a string, or a map with a require key and optional sibling keys:

antora:
  extensions:
  - '@antora/lunr-extension'
  - require: ./lib/unused-images-extension.js
    id: unused-images
    enabled: false
    fail_on_unused: true
  • require is the module to load. A path starting with ./ or ../ is resolved relative to the playbook file. A bare name is a package that must be installed in the project (npm i -D -E <package>), because Antora does not download packages for you.

  • enabled defaults to true. With enabled: false the entry is skipped and register is never called, which lets you keep the configuration in the playbook while the extension is off.

  • id gives the entry a short name for the command line. Without it, the require value is the identifier.

  • order can be auto (the default) or fixed, and affects the load order described below.

  • An entry without a require key is skipped with the warning Skipping Antora extension entry because require key not specified.

From the command line

The --extension option (repeatable) registers and enables an extension without editing the playbook. Its value is matched first against an id, then against a require value in the playbook. If nothing matches, it is treated as a new require request.

npx antora generate --extension unused-images antora-playbook.yml
npx antora generate --extension ./lib/unused-images-extension.js antora-playbook.yml

In Antora 3.2 a value prefixed with ! disables a registered extension for that run, and several values can be passed as repeated options:

npx antora generate --extension '!unused-images' antora-playbook.yml

Quote the value, because ! is special to many shells. The negation form was added in Antora 3.2.0 and is listed in the 3.2 release notes; the leading ! is also handled in the 3.2.1 source of the playbook builder.

Running the same playbook three times with the entry marked enabled: false showed the effect: with no option nothing was reported, with --extension unused-images the warning appeared, and with the entry enabled and --extension '!unused-images' nothing was reported again.

Load order

Antora calls register in list order. Extensions that are enabled in the playbook load first, in playbook order, and extensions enabled from the command line follow. If a --extension value matches an entry of the playbook, that entry moves to the command-line position (its playbook configuration still applies). Set order: fixed on the entry to keep its playbook position. Load order matters when one extension reads variables or files produced by another, for example an extension that depends on files generated by Collector.

Pitfalls and version notes

  • Always use the function keyword for register and for listeners that need this.

  • The componentsRegistered event and the ! negation on the command line are new in Antora 3.2.

  • Do not modify the playbook after playbookBuilt: it is frozen. Change content sources in playbookBuilt.

  • updateVariables fails on a locked variable. To adjust a catalog after it is locked, modify the object itself, or wrap it before it is locked (for example in contentClassified).

  • Do not rely on the relative order of uiLoaded, contentAggregated and componentsRegistered.

  • An extension that must run only in CI can be registered with enabled: false and turned on with --extension.

  • Log with getLogger rather than console: --log-failure-level then turns the warnings of your extension into a non-zero exit status (exit code 2 in the run above, with --log-failure-level=warn).

  • The extension examples on this page ran with Antora 3.2.1 and Node.js 24. The official extensions such as Lunr and Collector are covered on Site search and Collector, Atlas and Assembler.

  • The Mermaid and MathJax support of this site is an Asciidoctor extension and is configured as described in AsciiDoc attributes and extensions.