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.
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.
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.
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
}
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 would result in a 404 response from the web server.
For more fine-grained control for how a page is unpublished, you could write an extension that replaces the convertDocument
or convertDocuments
functions.
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.
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.
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.
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.
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.