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
})
}
Source: Antora docs: Extension use cases
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
thisset to the generator context. -
With a destructured parameter such as
{ config }, it is called withthisset to the context and the argument holdsconfigplus the variables that exist at that time (at registration onlyplaybook). -
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 |
|---|---|
|
Subscribes |
|
Same as |
|
Returns an Antora logger ( |
|
Returns a shallow copy of the current variables, for example |
|
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 |
|
Stops the run. The optional |
|
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 |
|
Return, or replace, the generator functions that implement each stage (for example |
Source: Antora docs: Extension use cases
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 |
|
|
Not an event: Antora calls the function so you can subscribe listeners. |
2 |
|
|
After initialization, including registration of extensions and generator functions. Built-in generator functions can be used from here. |
3 |
|
|
After the playbook is built. This is the customary place to modify content sources or other playbook data. The playbook is locked afterward. |
4 |
|
plus |
Before content processing starts. |
5 |
|
plus |
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 |
|
|
After component version objects exist, while the catalog is still being built. Added in Antora 3.2. An extension can add
files to the |
7 |
|
plus |
When the UI bundle is loaded. |
8 |
|
|
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 |
|
same |
After every page is converted from AsciiDoc to embeddable HTML. |
10 |
|
plus |
After the navigation trees are built. |
11 |
|
same as |
After each page is wrapped in the UI layout. |
12 |
|
same |
After redirect files for aliases are produced. |
13 |
|
same |
After the sitemap is produced. It is emitted only when |
14 |
|
same |
Last chance to add files to the site catalog, for example a |
15 |
|
plus |
After the files are published. |
16 |
|
any variable still in scope |
When |
17 |
|
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.
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.
'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.
'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)
})
}
Source: Antora docs: Extension use cases
The code makes these choices:
-
onceis enough becausedocumentsConvertedis emitted a single time per run;onwould 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 insideregisterinheritsthisfrom the enclosingfunction. -
The logger is created from the extension name, so each record carries the name unused-images-extension.
-
The
fail_on_unusedoption 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:
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
-
requireis 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. -
enableddefaults totrue. Withenabled: falsethe entry is skipped andregisteris never called, which lets you keep the configuration in the playbook while the extension is off. -
idgives the entry a short name for the command line. Without it, therequirevalue is the identifier. -
ordercan beauto(the default) orfixed, and affects the load order described below. -
An entry without a
requirekey is skipped with the warningSkipping 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
functionkeyword forregisterand for listeners that needthis. -
The
componentsRegisteredevent 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 inplaybookBuilt. -
updateVariablesfails 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 incontentClassified). -
Do not rely on the relative order of
uiLoaded,contentAggregatedandcomponentsRegistered. -
An extension that must run only in CI can be registered with
enabled: falseand turned on with--extension. -
Log with
getLoggerrather thanconsole:--log-failure-levelthen 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.