Create a UI Helper

This page explains how to create a UI helper for use in a page template (layout or partial). A helper is a JavaScript function that’s invoked by Handlebars when it comes across a helper call in a template.

Helper anatomy

A helper must be defined as a JavaScript file in the helpers directory of the UI bundle. The basename of the file without the file extension will be used as the function name. For example, if the helper is located at helpers/join.js, the name of the function will be join.

You don’t have to register the helper as Antora does that for you automatically. This automatic behavior replaces this Handlebars API call (which you don’t have to do):

Handlebars.registerHelper('join', function () { ... })

The helper file should export exactly one default function. The name of the function in the file does not matter.

Here’s a template of a helper function you can use as a starting point:

new-helper.js
'use strict'

module.exports = () => {
  return true
}

The return value of the function will be used in the logic in the template. If the helper is used in a conditional, it should return a boolean value (as in the previous example). If the helper is used to create output, it should return a string. If the helper is used in an iteration loop, it should return a collection.

We can now use our conditional helper in a template as follows:

{{#if (new-helper)}}
always true!
{{/if}}

The round brackets are always required around a helper function call (except in cases when they’re implied by Handlebars).

The helper can access top-level variables in the template by accepting the template context as the final parameter. The top-level variables are stored in in the data.root property of this object.

new-helper.js
'use strict'

module.exports = ({ data: { root } }) => {
  return root.site.url === 'https://docs.example.org'
}

Now our condition will change:

{{#if (new-helper)}}
Only true if the site URL is https://docs.example.org.
{{/if}}

A helper can also accept input parameters. These parameters get inserted in the parameter list before the context object. Handlebars only calls the function with the input parameters passed by the template, so it’s important to use a fixed number of them. Otherwise, the position of the context object will jump around.

new-helper.js
'use strict'

module.exports = (urlToCheck, { data: { root } }) => {
  return root.site.url === urlToCheck
}

Now we can accept the URL to check as an input parameter:

{{#if (new-helper 'https://docs.example.org')}}
Only true if the site URL matches the one specified.
{{/if}}

You can consult the Handlebars language guide for more information about creating helpers.

Use the content catalog in a helper

You can work directly with Antora’s content catalog in a helper to work with other pages and resources. Let’s define a helper that assembles a collection of pages that have a given tag defined in the page-tags attribute. The helper call will look something like this:

{{#each (pages-with-tag 'tutorial')}}

We’ll start by defining the helper in a file named pages-with-tag.js. In this first iteration, we’ll have it return a collection of raw virtual file objects from Antora’s content catalog. Populate the file with the following contents:

pages-with-tag.js
'use strict'

module.exports = (tag, { data }) => {
  const { contentCatalog } = data.root
  return contentCatalog.getPages(({ asciidoc, out }) => {
    if (!out || !asciidoc) return
    const pageTags = asciidoc.attributes['page-tags']
    return pageTags && pageTags.split(', ').includes(tag)
  })
}

Here we’re obtaining a reference to the content catalog, then filtering the pages by our criteria using the getPage() method. It’s always good to check for the presence of the out property to ensure the page is publishable.

Here’s how this helper is used in the template:

{{#each (pages-with-tag 'tutorial')}}
<a href="{{{relativize ./pub.url}}}">{{{./asciidoc.doctitle}}}</a>
{{/each}}

You’ll notice that the page objects in the collection differ from the typical page UI model. We can convert each page to a page UI model before returning the collection. Let’s write the extension again, this time running each page through Antora’s buildPageUiModel function:

pages-with-tag.js
'use strict'

module.exports = (tag, { data }) => {
  const { contentCatalog, site } = data.root
  const pages = contentCatalog.getPages(({ asciidoc, out }) => {
    if (!out || !asciidoc) return
    const pageTags = asciidoc.attributes['page-tags']
    return pageTags && pageTags.split(', ').includes(tag)
  })
  const { buildPageUiModel } = require.main.require('@antora/page-composer/build-ui-model')
  return pages.map((page) => buildPageUiModel(site, page, contentCatalog))
}

In this case, the usage of the item object is simpler and more familiar:

{{#each (pages-with-tag 'tutorial')}}
<a href="{{{relativize ./url}}}">{{{./doctitle}}}</a>
{{/each}}

Using this helper as a foundation, you can implement a variety of customizations and custom collections.

Keep in mind that any helper you will use will be called for each page that uses the template. This can impact performance. If it’s called on every page in your site, be sure that the operation is efficient to avoid slowing down site generation.

As an alternative to using a helper, you may want to consider whether writing an Antora extension is a better option.

Find latest release notes

Here’s another example of a helper that finds the latest release notes in a component named release-notes.

'use strict'

module.exports = (numOfItems, { data }) => {
  const { contentCatalog, site } = data.root
  if (!contentCatalog) return
  const rawPages = getDatedReleaseNotesRawPages(contentCatalog)
  const pageUiModels = turnRawPagesIntoPageUiModels(site, rawPages, contentCatalog)
  return getMostRecentlyUpdatedPages(pageUiModels, numOfItems)
}

let buildPageUiModel

function getDatedReleaseNotesRawPages (contentCatalog) {
  return contentCatalog.getPages(({ asciidoc, out }) => {
    if (!asciidoc || !out) return
    return getReleaseNotesWithRevdate(asciidoc)
  })
}

function getReleaseNotesWithRevdate (asciidoc) {
  const attributes = asciidoc.attributes
  return asciidoc.attributes && isReleaseNotes(attributes) && hasRevDate(attributes)
}

function isReleaseNotes (attributes) {
  return attributes['page-component-name'] === 'release-notes'
}

function hasRevDate (attributes) {
  return 'page-revdate' in attributes
}

function turnRawPagesIntoPageUiModels (site, pages, contentCatalog) {
  buildPageUiModel ??= module.parent.require('@antora/page-composer/build-ui-model').buildPageUiModel
  return pages
    .map((page) => buildPageUiModel(site, page, contentCatalog))
    .filter((page) => isValidDate(page.attributes?.revdate))
    .sort(sortByRevDate)
}

function isValidDate (dateStr) {
  return !isNaN(Date.parse(dateStr))
}

function sortByRevDate (a, b) {
  return new Date(b.attributes.revdate) - new Date(a.attributes.revdate)
}

function getMostRecentlyUpdatedPages (pageUiModels, numOfItems) {
  return getResultList(pageUiModels, Math.min(pageUiModels.length, numOfItems))
}

function getResultList (pageUiModels, maxNumberOfPages) {
  const resultList = []
  for (let i = 0; i < maxNumberOfPages; i++) {
    const page = pageUiModels[i]
    if (page.attributes?.revdate) resultList.push(getSelectedAttributes(page))
  }
  return resultList
}

function getSelectedAttributes (page) {
  const latestVersion = getLatestVersion(page.contents.toString())
  return {
    latestVersionAnchor: latestVersion?.anchor,
    latestVersionName: latestVersion?.innerText,
    revdateWithoutYear: removeYear(page.attributes?.revdate),
    title: cleanTitle(page.title),
    url: page.url,
  }
}

function getLatestVersion (contentsStr) {
  const firstVersion = contentsStr.match(/<h2 id="([^"]+)">(.+?)<\/h2>/)
  if (!firstVersion) return
  const result = { anchor: firstVersion[1] }
  if (isVersion(firstVersion[2])) result.innerText = firstVersion[2]
  return result
}

function isVersion (versionText) {
  return /^[0-9]+\.[0-9]+(?:\.[0-9]+)?/.test(versionText)
}

function removeYear (dateStr) {
  if (!isValidDate(dateStr)) return
  const dateObj = new Date(dateStr)
  return `${dateObj.toLocaleString('default', { month: 'short' })} ${dateObj.getDate()}`
}

function cleanTitle (title) {
  return title.split('Release Notes')[0].trim()
}

Here’s how might use it to create a list of release notes.

<ul>
{{#each (latest-release-notes 10)}}
  <li><a href="{{relativize ./url}}#{{./latestVersionAnchor}}">{{./title}} ({{./revdateWithoutYear}})</a></li>
{{/each}}
</ul>