Skip to content

Commit

Permalink
Override buildNavigation Extension
Browse files Browse the repository at this point in the history
Closes gh-28
  • Loading branch information
rwinch committed May 14, 2024
1 parent 25a507c commit 3df0600
Show file tree
Hide file tree
Showing 10 changed files with 2,886 additions and 9 deletions.
26 changes: 26 additions & 0 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@ The extension also maps the latest release to a URL that leverages only the `${m
NOTE: Filtering is intentionally performed on the tags rather than versions because the Spring team calculates versions by extracting the information from the Java based build.
The amount of time to calculate the version is small, but adds up with lots of tags, and we'd like to avoid this computational cost on tags that are not being used.


=== override-navigation-builder-extension

*require name:* @springio/antora-extensions/override-navigation-builder-extension

IMPORTANT: Be sure to register this extension under the `antora.extensions` key in the playbook, not the `asciidoc.extensions` key!

The purpose of this extension is override the navigation builder to work around https://gitlab.com/antora/antora/-/issues/701

The summary is that this allows xref entries in the navigation to propagate roles to the model.
The following will have a model that contains the role `custom`.

[source,asciidoc]
----
* xref::index.adoc[Optional Text, role=custom]
----

The following will have a model that contains the roles `a b`.

[source,asciidoc]
----
* xref::index.adoc[Optional Text, role=a b]
----

Additional attributes `title`, `target`, and `rel` are also propagated to the model.

=== Partial Build

*require name:* @springio/antora-extensions/partial-build-extension
Expand Down
144 changes: 144 additions & 0 deletions lib/navigation-builder/build-navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use strict'

const NavigationCatalog = require('./navigation-catalog')

const $unsafe = Symbol.for('unsafe')

// eslint-disable-next-line max-len
const LINK_RX =
/<a href="([^"]+)"(?: class="([^"]+)")?(?: title="([^"]+)")?(?: target="([^"]+)")?(?: rel="([^"]+)")?>(.+?)<\/a>/

/**
* Builds a {NavigationCatalog} from files in the navigation family that are
* stored in the content catalog.
*
* Queries the content catalog for files in the navigation family. Then uses
* the AsciiDoc Loader component to parse the source of each file into an
* Asciidoctor Document object. It then looks in each file for one or more nested
* unordered lists, which are used to build the navigation trees. It then
* combines those trees in sorted order as a navigation set, which gets
* stored in the navigation catalog by component/version pair.
*
* @memberof navigation-builder
*
* @param {ContentCatalog} [contentCatalog=undefined] - The content catalog
* that provides access to the virtual files in the site.
* @param {Object} [asciidocConfig={}] - AsciiDoc processor configuration options. Extensions are not propagated.
* Sets the relativizeResourceRefs option to false before passing to the loadAsciiDoc function.
* @param {Object} [asciidocConfig.attributes={}] - Shared AsciiDoc attributes to assign to the document.
*
* @returns {NavigationCatalog} A navigation catalog built from the navigation files in the content catalog.
*/
function buildNavigation (contentCatalog, siteAsciiDocConfig = {}) {
const { loadAsciiDoc = require('@antora/asciidoc-loader') } = this ? this.getFunctions($unsafe) : {}
const navCatalog = new NavigationCatalog()
const navAsciiDocConfig = { doctype: 'article', extensions: [], relativizeResourceRefs: false }
contentCatalog
.findBy({ family: 'nav' })
.reduce((accum, navFile) => {
const { component, version } = navFile.src
const key = version + '@' + component
const val = accum.get(key)
if (val) return new Map(accum).set(key, Object.assign({}, val, { navFiles: [...val.navFiles, navFile] }))
const componentVersion = contentCatalog.getComponentVersion(component, version)
const asciidocConfig = Object.assign({}, componentVersion.asciidoc || siteAsciiDocConfig, navAsciiDocConfig)
return new Map(accum).set(key, { component, version, componentVersion, asciidocConfig, navFiles: [navFile] })
}, new Map())
.forEach(({ component, version, componentVersion, asciidocConfig, navFiles }) => {
const trees = navFiles.reduce((accum, navFile) => {
accum.push(...loadNavigationFile(loadAsciiDoc, navFile, contentCatalog, asciidocConfig))
return accum
}, [])
componentVersion.navigation = navCatalog.addNavigation(component, version, trees)
})
return navCatalog
}

function loadNavigationFile (loadAsciiDoc, navFile, contentCatalog, asciidocConfig) {
const lists = loadAsciiDoc(navFile, contentCatalog, asciidocConfig).blocks.filter((b) => b.getContext() === 'ulist')
if (!lists.length) return []
const index = navFile.nav.index
return lists.map((list, idx) => {
const tree = buildNavigationTree(list.getTitle(), list.getItems())
tree.root = true
tree.order = idx ? parseFloat((index + idx / lists.length).toFixed(4)) : index
return tree
})
}

function getChildListItems (listItem) {
const blocks = listItem.getBlocks()
const candidate = blocks[0]
if (candidate) {
if (blocks.length === 1 && candidate.getContext() === 'ulist') {
return candidate.getItems()
} else {
let context
return blocks.reduce((accum, block) => {
if (
(context = block.getContext()) === 'ulist' ||
(context === 'open' && (block = block.getBlocks()[0]) && block.getContext() === 'ulist')
) {
accum.push(...block.getItems())
}
return accum
}, [])
}
} else {
return []
}
}

function buildNavigationTree (formattedContent, items) {
const entry = formattedContent ? partitionContent(formattedContent) : {}
if (items.length) entry.items = items.map((item) => buildNavigationTree(item.getText(), getChildListItems(item)))
return entry
}

// atomize? distill? decompose?
function partitionContent (content) {
if (~content.indexOf('<a')) {
const match = content.match(LINK_RX)
if (match) {
const [, url, role, title, target, rel, content] = match
const roles = role ? role.split(' ') : undefined
let result
if (roles && roles.includes('xref')) {
roles.splice(roles.indexOf('xref'), 1)
if (roles.includes('page')) {
roles.splice(roles.indexOf('page'), 1)
}
const hashIdx = url.indexOf('#')
if (~hashIdx) {
if (roles.includes('unresolved')) {
result = { content, url, urlType: 'internal', unresolved: true }
} else {
result = { content, url, urlType: 'internal', hash: url.substr(hashIdx) }
}
} else {
result = { content, url, urlType: 'internal' }
}
} else if (url.charAt() === '#') {
result = { content, url, urlType: 'fragment', hash: url }
} else {
result = { content, url, urlType: 'external' }
}
if (roles && roles.length) {
result.roles = roles.join(' ')
}
if (title) {
result.title = title
}
if (target) {
result.target = target
}
if (rel) {
result.rel = rel
}
return result
}
}
return { content }
}

module.exports = buildNavigation
32 changes: 32 additions & 0 deletions lib/navigation-builder/navigation-catalog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use strict'

const $sets = Symbol('sets')

class NavigationCatalog {
constructor () {
this[$sets] = {}
}

addTree (component, version, tree) {
const key = generateKey(component, version)
const navigation = this[$sets][key] || (this[$sets][key] = [])
// NOTE retain order on insert
const insertIdx = navigation.findIndex((candidate) => candidate.order >= tree.order)
~insertIdx ? navigation.splice(insertIdx, 0, tree) : navigation.push(tree)
return navigation
}

addNavigation (component, version, trees) {
return (this[$sets][generateKey(component, version)] = trees.sort((a, b) => a.order - b.order))
}

getNavigation (component, version) {
return this[$sets][generateKey(component, version)]
}
}

function generateKey (component, version) {
return version + '@' + component
}

module.exports = NavigationCatalog
11 changes: 11 additions & 0 deletions lib/navigation-builder/override-navigation-builder-extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use strict'

const buildNavigation = require('./build-navigation')

module.exports.register = function () {
this.replaceFunctions({
buildNavigation (contentCatalog, siteAsciiDocConfig) {
return buildNavigation.call(this, contentCatalog, siteAsciiDocConfig)
},
})
}
Loading

0 comments on commit 3df0600

Please sign in to comment.