扩展使用案例

本页面提供了一系列简单示例目录,展示了如何通过使用扩展来增强Antora的功能。每个部分介绍了一个不同的用例,并呈现了您可以构建的扩展代码作为起点。

您还可以参考Antora项目提供的官方扩展项目,以研究更复杂的示例。

设置全局AsciiDoc属性

如果您想定义具有动态值的全局AsciiDoc属性,可以使用扩展来实现。Playbook保存AsciiDoc配置对象,该对象本身包含全局AsciiDoc属性。扩展可以监听playbookBuilt事件,并向该映射添加属性。

示例1. set-global-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.on('beforeProcess', ({ siteAsciiDocConfig }) => {
    const buildDate = new Date().toISOString()
    siteAsciiDocConfig.attributes['build-date'] = buildDate
  })
}

扩展还可以从文件或环境变量中读取这些值。

如果您需要设置针对组件版本范围的AsciiDoc属性,则需要监听contentClassified事件。从那里,您可以访问组件版本对象上的asciidoc属性,以获取AsciiDoc属性。您可以使用内容目录对象上的getComponentVersion方法按名称和版本查找组件版本。或者,您可以从内容目录对象上的getComponents方法返回的每个组件的versions属性中访问组件版本。

如果您正在排除站点问题,可以使用扩展生成站点级别和每个组件版本的AsciiDoc属性报告。在制作此报告时,您可以选择是否要显示AsciiDoc属性,如它们在页面上可用(已编译)或如定义的(未编译)。

您可以使用以下扩展打印为每个组件版本编译的所有AsciiDoc属性。该扩展还打印从playbook编译的所有属性,尽管请记住这些属性已集成到每个组件版本的属性中。

示例2. print-compiled-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.once('contentClassified', ({ siteAsciiDocConfig, contentCatalog }) => {
    console.log('站点范围属性(已编译)')
    console.log(siteAsciiDocConfig.attributes)
    contentCatalog.getComponents().forEach((component) => {
      component.versions.forEach((componentVersion) => {
        console.log(`${componentVersion.version}@${componentVersion.name} 属性(已编译)`)
        if (componentVersion.asciidoc === siteAsciiDocConfig) {
          console.log('与站点范围属性相同')
        } else {
          console.log(componentVersion.asciidoc.attributes)
        }
      })
    })
  })
}

您可以使用以下扩展打印为每个组件版本(按来源)在playbook和antora.yml文件中定义的所有AsciiDoc属性。

示例3. print-defined-asciidoc-attributes-extension.js
module.exports.register = function () {
  this.once('contentClassified', ({ playbook, contentCatalog }) => {
    console.log('站点范围属性(如playbook中定义)')
    console.log(playbook.asciidoc.attributes)
    contentCatalog.getComponents().forEach((component) => {
      component.versions.forEach((componentVersion) => {
        getUniqueOrigins(contentCatalog, componentVersion).forEach((origin) => {
          console.log(`${componentVersion.version}@${componentVersion.name} 属性(如在antora.yml中定义)`)
          console.log(origin.descriptor.asciidoc?.attributes || {})
        })
      })
    })
  })
}

function getUniqueOrigins (contentCatalog, componentVersion) {
  return contentCatalog.findBy({ component: componentVersion.name, version: componentVersion.version })
    .reduce((origins, file) => {
      const origin = file.src.origin
      if (origin && !origins.includes(origin)) origins.push(origin)
      return origins
    }, [])
}

在编写其他扩展时,您可能会发现使用这些AsciiDoc属性集合很有用。

排除私有内容源

如果某些贡献者或CI作业没有权限访问playbook中的私有内容源,您可以使用扩展来过滤它们,而无需修改playbook文件。

此扩展在playbookBuilt事件期间运行。它检索playbook,遍历内容源,并删除任何被检测为私有且因此需要身份验证的内容源。我们将依赖一种约定来向扩展传达哪些内容源是私有的。该约定是使用以git@开头的SSH URL。Antora会自动将SSH URL转换为HTTP URL,因此使用此语法仅仅作为用户和扩展的提示,表明该URL是私有的,并且将请求身份验证。

示例4. exclude-private-content-sources-extension.js
module.exports.register = function () {
  this.on('playbookBuilt', function ({ playbook }) {
    playbook.content.sources = playbook.content.sources
      .filter(({ url }) => !url.startsWith('git@'))
    this.updateVariables({ playbook })
  })
}

此扩展之所以有效,是因为playbook在此事件结束前是可变的,Antora在此时将其冻结。调用this.updateVariables来替换生成器上下文中的playbook变量并不是必需的,但在这里用于表达意图并未来保护扩展。

取消发布标记页面

如果您不希望某个页面被发布,可以在文件名前加下划线(例如,_hidden.adoc)。但是,如果您只希望页面在特定条件下不被发布,则需要使用扩展。

使用此扩展,任何设置了page-unpublish页面属性的页面将不会被发布(意味着它将被取消发布)。例如:

= 秘密页面
:page-unpublish:

此页面将不会被发布。

您可以基于另一个AsciiDoc属性的存在(或不存在)来设置page-unpublish页面属性。例如:

= 秘密页面
ifndef::include-secret[:page-unpublish:]

此页面将不会被发布。

此扩展在documentsConverted事件期间运行。这是提供对虚拟文件上AsciiDoc元数据访问的最早事件。该扩展遍历内容目录中所有可发布页面,并取消发布设置了page-unpublish属性的任何页面。为了取消发布页面,扩展会删除虚拟文件上的out属性。如果out属性不存在,则页面将不会被发布。

示例5. page-unpublish-tag-extension.js
module.exports.register = function () {
  this.on('documentsConverted', ({ contentCatalog }) => {
    contentCatalog.getPages((page) => page.out).forEach((page) => {
      if (page.asciidoc?.attributes['page-unpublish'] != null) {
        delete page.out
      }
    })
  })
}

请注意,未发布页面可能会有对它的引用。虽然Antora会解析这些引用,但引用的目标将不可用,这将导致Web服务器返回404响应。

如果您希望更精细地控制页面何时取消发布,您可以编写一个替换convertDocumentconvertDocuments函数的扩展。这样做可以让您在其他页面引用它之前取消发布页面,以便它们显示为警告。

报告未列出的页面

在创建新页面后,很容易忘记将其添加到导航中,以便读者可以访问。我们可以使用扩展来识别未包含在导航中的页面,并使用记录器报告它们。

此扩展在 navigationBuilt 事件期间运行。它遍历每个组件版本,检索其内部导航条目的扁平列表,然后检查是否有任何未包含在该列表中的页面,通过URL比较页面。如果找到任何这样的页面,它将创建一个报告,可选择将它们添加到导航中。

示例 6. unlisted-pages-extension.js
module.exports.register = function ({ config }) {
  const { addToNavigation, unlistedPagesHeading = '未列出的页面' } = 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 }, '检测到未列出的页面')
              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
}

您可以在 扩展教程 中阅读更多关于此扩展及如何配置它的信息。

取消发布未列出的页面

与报告未列出的页面不同,您可以选择从发布中移除这些页面。这是您可以使用导航来驱动哪些页面被发布的一种方式。

此扩展在 navigationBuilt 事件期间运行。它遍历每个组件版本,检索其内部导航条目的扁平列表,然后检查是否有任何未包含在该列表中的页面,通过URL比较页面。如果找到任何这样的页面,它将取消发布它们。

示例 7. unpublish-unlisted-pages-extension.js
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
}

通过从页面中删除 out 属性,可以防止页面被发布,但仍可使用包含指令引用。或者,您可以选择从内容目录中完全删除页面。

列出发现的组件版本

在设置 playbook 时,您可能会发现 Antora 没有发现您的某些组件版本。使用扩展,可以列出 Antora 在内容聚合期间发现的组件版本,以及它们所来自的内容源。

示例 8. discovered-component-versions-extension.js
module.exports.register = function () {
  this.once('contentAggregated', ({ contentAggregate }) => {
    console.log('发现以下组件版本')
    contentAggregate.forEach((bucket) => {
      const sources = bucket.origins.map(({ url, refname }) => ({ url, refname }))
      console.log({ name: bucket.name, version: bucket.version, files: bucket.files.length, sources })
    })
  })
}

如果缺少条目,则您可能需要调整 playbook 中的内容源定义。

要获取更多信息,您可以打印整个 bucket 条目。

解析附件中的属性引用

附件系列中的文件直接传递到输出站点。Antora 不会解析附件文件中的 AsciiDoc 属性引用。(另一方面,如果在启用属性替换的 AsciiDoc 页面中包含附件,则 Asciidoctor 将仅解析附件内容中的 AsciiDoc 属性引用。)您可以使用 Antora 扩展来使 Antora 在发布该文件之前解析附件文件中的属性引用。

此扩展在 contentClassified 事件期间运行,这是首次识别和分类附件文件的时候。它遍历所有附件并解析任何引用到该附件组件版本范围内的属性。如果对文件内容进行了任何更改,则将更新后的值替换虚拟文件上的内容。

示例 9. resolve-attributes-references-in-attachments-extension.js
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)
    })
  })
}

此扩展仅已知适用于基于文本的附件。您可能需要修改此扩展以使其适用于二进制文件。

将文字处理附件转换为PDF

与Antora将AsciiDoc文件(.adoc)转换为HTML(.html)类似,您也可以对附件执行相同操作。此扩展在contentClassified事件期间运行,这是首次识别和分类附件文件的时候。它会遍历所有以文字处理格式(即.docx,.odt,.fodt)保存的附件,并使用libreoffice命令(LibreOffice 服务器模式)将每个文件转换为PDF。

示例 10. doc-to-pdf-extension.js
const fsp = require('node:fs/promises')
const ospath = require('node:path')
const { posix: path } = ospath
const { execFile } = require('node:child_process')

module.exports.register = function () {
  this.once('contentClassified', async ({ playbook, contentCatalog }) => {
    const docExtnames = { '.docx': true, '.fodt': true, '.odt': true }
    const filesToConvert = contentCatalog.getFiles().filter(({ src }) => src.family === 'attachment' && docExtnames[src.extname])
    if (!filesToConvert.length) return
    const buildDirBase = ospath.join(playbook.dir, 'build/doc-to-pdf')
    const convertArgs = ['--writer', '--convert-to', 'pdf']
    const convertOpts = { cwd: buildDirBase, windowsHide: true }
    try {
      await fsp.mkdir(buildDirBase, { recursive: true })
      await Promise.all(filesToConvert.map((file) => {
        const sourceRelpath = `${file.src.component}-${file.src.module}-${file.out.basename}`
        convertArgs.push(sourceRelpath)
        return fsp.writeFile(ospath.join(buildDirBase, sourceRelpath), file.contents)
      }))
      await new Promise((resolve, reject) => {
        execFile('libreoffice', convertArgs, convertOpts, (err, stderr, stdout) => {
          if (!err) return resolve()
          const splitIdx = stderr.indexOf('Usage: ')
          if (~splitIdx) stderr = stderr.slice(0, splitIdx).trimEnd()
          if (stderr) err.message += stderr
          reject(err)
        })
      })
      await Promise.all(filesToConvert.map((file) => {
        file.out.path = path.join(file.out.dirname, (file.out.basename = file.out.basename.slice(0, -file.src.extname.length) + '.pdf'))
        file.pub.url = file.pub.url.slice(0, -file.src.extname.length) + '.pdf'
        const sourceRelpath = `${file.src.component}-${file.src.module}-${file.out.basename}`
        return fsp.readFile(ospath.join(buildDirBase, sourceRelpath)).then((contents) => (file.contents = contents))
      }))
    } finally {
      await fsp.rm(buildDirBase, { recursive: true, force: true })
    }
  })
}

通过转换文件并更新元数据,可以使用xref宏引用源文档。该引用将自动转换为生成站点中PDF的链接。