Custom Exporter Extension

With relatively little effort, you can create your own exporter extension for Assembler. That’s because Assembler exposes a configure function that sets and runs the assembly process. All you need to do is provide the converter.

Primer

An exporter is an Antora extension. Thus, to start, you create the basic structure of an Antora extension.

module.exports.register = function ({ config }) {}

Next, you define a converter. A converter consists of the following properties:

convert

An async function (or function that returns a Promise) that converts the assembly file to the target format. Typically, this function passes the AsciiDoc source (by way of stdin) to an external command such as asciidoctor-pdf to convert it to the target format. You can use the provided runCommand helper function to invoke the external command.

getDefaultCommand

An optionally async function that returns the default command to use when no command is specified in the config. (optional)

backend

The target backend; the backend that will be set during conversion (e.g., epub). (optional, default: the file extension without the leading dot)

extname

The file extension of the target format (e.g., .epub).

mediaType

The MIME type of the target format (e.g., application/epub+zip).

embedReferenceStyle

Controls whether images (for non-HTML filetypes) are rewritten relative to the export file (relative) or the output directory (output-relative). The docdir attribute passed to the convert function is set accordingly. (optional, default: relative)

This setting is not used when exporting to HTML (since images are linkable resources in that case). Instead, the link_reference_style key on the Assembler configuration is used. The reference style for embeds is always specified by the converter.

loggerName

The name of the Antora logger to use when logging messages captured from stderr of the command (e.g., @antora/epub-extension). (optional, default: @antora/assembler)

Here’s an example of a converter object:

const converter = {
  convert,
  backend: 'ext',
  extname: '.ext',
  mediaType: 'application/ext',
  loggerName: 'assembler-ext-extension',
}

The convert function has the following API:

async function convert (file, convertAttributes, buildConfig, helpers)

The parameters are as follows:

  • file - the assembly file. You’re typically only interested in the file.contents property, which contains the AsciiDoc source buffer.

  • convertAttributes - an object of AsciiDoc attributes to pass to the converter. These attributes can be converted to CLI options by calling .toArgs('-a'), where -a is the CLI option flag. You’ll want to honor the attributeOptionFlag if set on the configuration object.

  • buildConfig - a configuration object that provides both the command and the cwd.

  • helpers - an optional object that provides access to the built-in logCommand and runCommand helper functions for convenience, if needed (e.g., { logCommand, runCommand }).

Note that each assembly file is only converted once. If you’re using multiple exporters, a new assembly file is created per exporter.

The acceptable return values for the convert function are as follows:

  • contents as undefined, null, Buffer, or Stream

  • file

    • can have the same identity as the file parameter (does not have to be a copy)

    • if extname property matches that of export, used as is

    • otherwise, the file is coerced to the export file

  • runCommand result

    • contents or file may be passed using property by the same name

    • otherwise, if output_option_flag is false, content is read from stdout property

If the contents is undefined, Assembler will lazy read the file from the location specified by the outfile attribute (if that file exists, otherwise the contents will be null). If the contents is null, Assembler will not create or publish the export.

The convert function should pass the stderr key on the buildConfig object to the runCommand helper and return its result in order for the built-in stderr handling to work.

The optional getDefaultFunction has the following API:

async function getDefaultCommand (cwd, playbook)
If the converter object does not include the getDefaultCommand function, and the command property is not specified in the configuration, the command property on the buildConfig object will be null.

You then pass generator context, converter, and extension config to the configure function provided by Assembler.

this.require('@antora/assembler').configure(this, converter, config)

Here’s how it looks all together:

const converter = {
  convert,
  backend: 'ext',
  extname: '.ext',
  mediaType: 'application/ext',
  loggerName: 'assembler-ext-extension',
}

module.exports.register = function ({ config }) {
  this.require('@antora/assembler').configure(this, converter, config)
}

The example in the next section provides a full working implementation.

Example: DocBook

Here’s an example of how to create a DocBook extension that uses Asciidoctor by default.

Example 1. docbook-exporter-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')

const DEFAULT_COMMAND = 'asciidoctor -b docbook'

const converter = {
  convert,
  backend: 'docbook',
  getDefaultCommand,
  extname: '.xml',
  mediaType: 'application/docbook+xml',
  loggerName: 'assembler-docbook',
}

async function convert (file, convertAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const extraArgs = convertAttributes.toArgs(attributeOptionFlag || '-a')
  extraArgs.push(outputOptionFlag || '-o', convertAttributes.outfile, '-')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  return runCommand(command, extraArgs, opts)
}

function getDefaultCommand (cwd, playbook) {
  const defaultCommand = playbook?.runtime.stacktrace ? `${DEFAULT_COMMAND} --trace` : DEFAULT_COMMAND
  return fsp.access(ospath.join(cwd, 'Gemfile.lock')).then(
    () => `bundle exec ${defaultCommand}`,
    () => defaultCommand
  )
}

module.exports.register = function ({ config }) {
  this.require('@antora/assembler').configure(this, converter, config)
}

Notice that the extension delegates to Assembler to configure itself. It passes the convert function alongside metadata about the target format.

Example: DocBook to PDF

DocBook isn’t a format most visitors of the site are going to be able to view. Unless your only goal is to export the content from Antora, you probably want to convert to an end-user format such as PDF by way of DocBook.

It’s possible to have the convert function run two consecutive commands, one to convert from AsciiDoc to AsciiDoc and one to convert from DocBook to PDF. (Alternately, you could write a script that combines both steps into a single call).

Here’s an updated version of the DocBook exporter that takes the conversion all the way to PDF.

Example 2. docbook-pdf-exporter-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')

const DEFAULT_COMMAND = 'asciidoctor -b docbook'

const converter = {
  convert,
  backend: 'pdf',
  getDefaultCommand,
  extname: '.pdf',
  mediaType: 'application/pdf',
  loggerName: 'assembler-docbook-pdf',
}

async function convert (file, convertAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const outfile = convertAttributes.outfile
  const docbookOutfile = (convertAttributes.outfile = convertAttributes.outfile.replace(
    new RegExp(`\\${convertAttributes.outfilesuffix}$`),
    (convertAttributes.outfilesuffix = '.xml')
  ))
  const extraArgs = convertAttributes.toArgs(attributeOptionFlag || '-a')
  extraArgs.push(outputOptionFlag || '-o', docbookOutfile, '-')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  return runCommand(command, extraArgs, opts).then(() => {
    convertAttributes.outfile = outfile
    return runCommand('fopub', ['-f', 'pdf', docbookOutfile], { cwd, stdout: 'print', stderr })
  })
}

function getDefaultCommand (cwd, playbook) {
  const defaultCommand = playbook?.runtime.stacktrace ? `${DEFAULT_COMMAND} --trace` : DEFAULT_COMMAND
  return fsp.access(ospath.join(cwd, 'Gemfile.lock')).then(
    () => `bundle exec ${defaultCommand}`,
    () => defaultCommand
  )
}

module.exports.register = function ({ config }) {
  this.require('@antora/assembler').configure(this, converter, config)
}

This exporter uses a DocBook toolchain frontend named fopub to convert from DocBook to PDF. You can use whatever application works best for you (e.g., xmlto).

The convert function does have to do some fiddling with the outfile and outfilesuffix attributes so they have the correct values when converting to DocBook. The return value of the first command is also ignored, so some work probably needs to be done to fuse the stderr from both commands.

If you configure the embedReferenceStyle for the DocBook exporter extension to be output-relative instead of the default relative, you will need to pass the following parameters to the DocBook toolchain in order for images to be resolved when generating the PDF.

<xsl:param name="img.src.path">${buildConfig.dir}</xsl:param>
<xsl:param name="keep.relative.image.uri">1</xsl:param>

Example: DocBook to DOCX

Similar to the previous example, you can convert from DocBook to DOCX.

Here’s an updated version of the DocBook exporter that takes the conversion all the way to DOCX.

Example 3. docbook-docx-exporter-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')

const DEFAULT_COMMAND = 'asciidoctor -b docbook'

const converter = {
  convert,
  backend: 'docx',
  getDefaultCommand,
  extname: '.docx',
  mediaType: 'application/msword',
  loggerName: 'assembler-docbook-docx',
}

async function convert (file, convertAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const outfile = convertAttributes.outfile
  const docbookOutfile = (convertAttributes.outfile = convertAttributes.outfile.replace(
    new RegExp(`\\${convertAttributes.outfilesuffix}$`),
    (convertAttributes.outfilesuffix = '.xml')
  ))
  const extraArgs = convertAttributes.toArgs(attributeOptionFlag || '-a')
  extraArgs.push(outputOptionFlag || '-o', docbookOutfile, '-')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  return runCommand(command, extraArgs, opts).then(() => {
    convertAttributes.outfile = outfile
    const pandocArgs = ['-f', 'docbook', '-t', 'docx', '-o', outfile, docbookOutfile]
    const pandocOpts = { cwd, stdout: 'print', stderr }
    return runCommand('pandoc', pandocArgs, pandocOpts)
  })
}

function getDefaultCommand (cwd, playbook) {
  const defaultCommand = playbook?.runtime.stacktrace ? `${DEFAULT_COMMAND} --trace` : DEFAULT_COMMAND
  return fsp.access(ospath.join(cwd, 'Gemfile.lock')).then(
    () => `bundle exec ${defaultCommand}`,
    () => defaultCommand
  )
}

module.exports.register = function ({ config }) {
  this.require('@antora/assembler').configure(this, converter, config)
}

This exporter uses pandoc to convert from DocBook to PDF. You can use whatever application works best for you.

The convert function does have to do some fiddling with the outfile and outfilesuffix attributes so they have the correct values when converting to DocBook. The return value of the first command is also ignored, so some work probably needs to be done to fuse the stderr from both commands.