Custom Exporter Examples

This page provides numerous examples demonstrating how to create a custom exporter extension for Assembler.

Example: DocBook

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

Example 1. lib/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',
  xmlCompliant: true,
}

async function convert (file, conversionAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const extraArgs = conversionAttributes.toArgs(attributeOptionFlag || '-a', outputOptionFlag || '-o')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  return runCommand(command, extraArgs.concat('-'), 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 }) {
  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 use 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. lib/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: 'docbook-pdf',
  format: 'pdf',
  getDefaultCommand,
  extname: '.pdf',
  mediaType: 'application/pdf',
  xmlCompliant: true,
}

async function convert (file, conversionAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  conversionAttributes.outfilesuffix = '.xml'
  const docbookOutfile = conversionAttributes.outfile
  const extraArgs = conversionAttributes.toArgs(attributeOptionFlag || '-a', outputOptionFlag || '-o')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  const result1 = await runCommand(command, extraArgs.concat('-'), opts)
  conversionAttributes.outfilesuffix = converter.extname
  const fopubCmd = ospath.join(process.env.FOPUB_HOME ?? '', 'fopub')
  const fopubArgs = [ospath.relative(process.cwd(), docbookOutfile)]
  const result2 = await runCommand(fopubCmd, fopubArgs, { stdout: 'print', stderr })
  if (stderr === 'buffer') result2.stderr = Buffer.concat([result1.stderr, result2.stderr])
  return result2
}

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 }) {
  require('@antora/assembler').configure(this, converter, config)
}

This exporter uses a DocBook toolchain frontend named fopub to convert from DocBook to PDF. To use it, you must set the FOPUB_HOME environment variable to the location where the fopub project is cloned. Alternately, you can update the converter to use whatever application works best for you (e.g., xmlto).

Whenver you need to use multiple commands, you could create a script to run both commands, then have the converter invoke that script. However, in that case, you’ll need to preprocess the arguments so the attributes are only passed to the first command, and that the input and output files for the second command are correct.

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.

If you configure the embedReferenceStyle for the DocBook exporter 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. lib/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',
  xmlCompliant: true,
}

async function convert (file, conversionAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const outfile = conversionAttributes.outfile
  conversionAttributes.outfilesuffix = '.xml'
  const docbookOutfile = conversionAttributes.outfile
  const extraArgs = conversionAttributes.toArgs(attributeOptionFlag || '-a', outputOptionFlag || '-o')
  const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
  const result1 = await runCommand(command, extraArgs.concat('-'), opts)
  conversionAttributes.outfilesuffix = converter.extname
  const pandocArgs = ['-f', 'docbook', '-t', 'docx', '-o', outfile, docbookOutfile]
  const result2 = await runCommand('pandoc', pandocArgs, { cwd: conversionAttributes.outdir, stdout: 'print', stderr })
  if (stderr === 'buffer') result2.stderr = Buffer.concat([result1.stderr, result2.stderr])
  return result2
}

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 }) {
  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.

Whenver you need to use multiple commands, you could create a script to run both commands, then have the converter invoke that script. However, in that case, you’ll need to preprocess the arguments so the attributes are only passed to the first command, and that the input and output files for the second command are correct.

Example: Use an attributes file

Assembler passes convert attributes to the command as CLI options (e.g., -a outfilesuffix=.pdf). If your site or Assembler configuration has a lot of attributes, it’s possible this approach can result in a command whose length exceeds the limit enforced by the shell. To work around this issue, we can create a custom exporter that writes the attribute to a file instead.

The first part of this solution is to create a custom wrapper for Asciidoctor PDF.

Example 4. lib/asciidoctor-pdf-with-attributes-file.rb
require 'asciidoctor'
require 'asciidoctor/cli'
require 'asciidoctor-pdf'
require 'yaml'

args = ARGV.dup
if (theme_arg_idx = args.index '--theme') && (theme_val = args[theme_arg_idx + 1])
  args[theme_arg_idx] = '-a'
  args[theme_arg_idx + 1] = %(pdf-theme=#{theme_val})
end
options = Asciidoctor::Cli::Options.new backend: 'pdf', header_footer: true
case (result = options.parse! args)
when Integer
  exit result
else
  outfile = options[:output_file]
  attributes_file = File.join (File.dirname outfile), ((File.basename outfile, (File.extname outfile)) + '.attrs.yml')
  attributes = YAML.load_file attributes_file
  options[:attributes].update attributes
  invoker = Asciidoctor::Cli::Invoker.new options
  invoker.invoke!
  exit invoker.code
end

This CLI parses the arguments passed to the command, then it reads the attributes from the attributes file and adds those to the parsed options. Take note that it has to convert the --theme option since that option is not handled by the Asciidoctor CLI.

It is possible to use an Asciidoctor preprocessor extension to load the attributes from a file instead of wrapping the CLI. However, that solution requires a bit more work since it has to process the attribute modifiers.

Next, we’ll create a custom exporter that works just like the built-in PDF exporter, except it writes the convert attributes to a file in the same directory as the outfile (and docfile) for the Asciidoctor PDF wrapper to read.

Example 5. lib/pdf-exporter-with-attributes-file-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')

const DEFAULT_COMMAND = 'ruby ./lib/asciidoctor-pdf-with-attributes-file.rb'

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

async function convert (file, conversionAttributes, buildConfig, { runCommand }) {
  const { command, cwd = process.cwd(), outputOptionFlag, stderr = 'print' } = buildConfig
  if (!command) return null
  const yaml = this.require('js-yaml')
  const extraArgs = []
  if ('asciidoctor-log-integration' in conversionAttributes) {
    const asciidoctorLogAdapter = conversionAttributes['asciidoctor-log-integration']
    if (asciidoctorLogAdapter) extraArgs.push('-r', asciidoctorLogAdapter)
    delete conversionAttributes['asciidoctor-log-integration']
  }
  const attributesFile = ospath.join(
    ospath.dirname(conversionAttributes.outfile),
    ospath.basename(conversionAttributes.outfile, converter.extname) + '.attrs.yml'
  )
  await fsp.writeFile(attributesFile, yaml.dump(conversionAttributes))
  extraArgs.push(outputOptionFlag || '-o', conversionAttributes.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 }) {
  require('@antora/assembler').configure(this, converter, config)
}

Using this extension, the length of the command Assembler runs will remain fixed regardless of how many attributes are defined.

Notice that we are retrieving the Asciidoctor log adapter from the asciidoctor-log-integration attribute. Assembler prepares the adapter script and assigns it to this attribute if set in the Assembler configuration.

Example: Markdown

You can use Assembler to export the content in your site to Markdown, perhaps to be consumed by another tool.

Here’s an example of how to create a Markdown exporter that uses downdoc by default.

Example 6. lib/markdown-exporter-extension.js
const fsp = require('node:fs/promises')

const DEFAULT_COMMAND = 'npx -y downdoc'

const converter = {
  convert,
  backend: 'markdown',
  getDefaultCommand,
  extname: '.md',
  mediaType: 'text/markdown',
}

async function convert (file, conversionAttributes, buildConfig, { runCommand }) {
  const command = buildConfig.command
  if (!command && command !== false) return null
  if (!buildConfig.mkdirs) await fsp.mkdir(conversionAttributes.outdir, { force: true, recursive: true })
  if (command) {
    const { cwd = process.cwd(), attributeOptionFlag, outputOptionFlag, stderr = 'print' } = buildConfig
    const extraArgs = conversionAttributes.toArgs(attributeOptionFlag || '-a', outputOptionFlag || '-o')
    const opts = { parse: true, cwd, stdin: file.contents, stdout: 'print', stderr }
    return runCommand(command, extraArgs.concat('-'), opts)
  }
  const output = require('downdoc')(file.contents.toString(), { attributes: conversionAttributes })
  await fsp.writeFile(conversionAttributes.outfile, output)
}

function getDefaultCommand () {
  return DEFAULT_COMMAND
}

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

The convert function will use the downdoc API directly if the command is specified as false. Otherwise, it will use the downdoc bin script, prefixing it with npx -y to install it on demand.