@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
670 lines (589 loc) • 25.2 kB
JavaScript
const yaml = require('js-yaml')
/**
* Unified Navigation Extension (Config-Driven)
*
* Creates a unified navigation structure based on component configuration.
* Components define their navigation hierarchy via the 'page-navigation' attribute in antora.yml.
* Supports nested buckets with version detection and breadcrumb hierarchy computation.
*
* Configuration: No playbook configuration needed - reads from component antora.yml files.
*
* Component antora.yml configuration:
* asciidoc:
* attributes:
* component-metadata:
* title: "Component Name"
* color: "#hexcolor"
* icon: "tabler-icon-name"
* order: 10
* page-navigation:
* - component-name
* - parent-component:
* - child-component-1
* - child-component-2
*
* Output: Attaches to pages with page-navigation config:
* - 'page-custom-navigation' (JSON array of buckets)
* - 'page-has-custom-nav' (boolean)
* - 'page-is-umbrella-nav' (boolean)
* - 'page-breadcrumb-hierarchy' (JSON array of breadcrumb items)
*
* Components without page-navigation use standard Antora navigation.
*/
module.exports.register = function () {
const logger = this.getLogger('unified-navigation-extension')
this.on('navigationBuilt', ({ contentCatalog }) => {
try {
// Build a set of published page URLs (pages that have page.out defined)
const allPages = contentCatalog.getPages()
const publishedUrlsSet = new Set(
allPages
.filter((page) => page.out && page.pub && page.pub.url)
.map((page) => page.pub.url)
)
const components = contentCatalog.getComponents()
// Build component version navigation map (used by both config-driven and section-based nav)
const componentVersionNavMap = buildComponentVersionNavMap(components)
// PHASE 1: Config-Driven Navigation
// Step 1: Collect all components with page-navigation config
const pages = contentCatalog.getPages()
const configDrivenPages = new Set()
const componentConfigs = new Map() // component -> {configTree, configOwner}
// First pass: find components with page-navigation config
for (const component of components) {
for (const version of component.versions) {
const navConfig = version.asciidoc?.attributes?.['page-navigation']
if (navConfig) {
try {
const configTree = parseNavigationConfig(navConfig)
if (configTree.length > 0) {
// Store config and extract all component names mentioned
const allComponentsInConfig = new Set()
const extractComponents = (tree) => {
for (const item of tree) {
allComponentsInConfig.add(item.name)
if (item.children) {
extractComponents(item.children)
}
}
}
extractComponents(configTree)
// Store config for the owner
// A component's OWN config always takes priority (regardless of depth)
const existingOwnerConfig = componentConfigs.get(component.name)
if (!existingOwnerConfig || existingOwnerConfig.configOwner !== component.name) {
// No existing config, or existing config is from another component - use this one
componentConfigs.set(component.name, {
configTree,
configOwner: component.name,
allComponents: allComponentsInConfig,
})
} else {
// Existing config is also from this component - compare depths
const existingOwnerDepth = getComponentDepth(existingOwnerConfig.configTree, component.name)
const newOwnerDepth = getComponentDepth(configTree, component.name)
if (newOwnerDepth >= existingOwnerDepth) {
// New config has equal or better hierarchy, use it
componentConfigs.set(component.name, {
configTree,
configOwner: component.name,
allComponents: allComponentsInConfig,
})
}
}
// Also store this config for all child components
// Prefer configs with deeper nesting (more parent context for breadcrumbs)
for (const childComponent of allComponentsInConfig) {
const existingConfig = componentConfigs.get(childComponent)
const newDepth = getComponentDepth(configTree, childComponent)
if (!existingConfig) {
// No existing config, store this one
componentConfigs.set(childComponent, {
configTree,
configOwner: component.name,
allComponents: allComponentsInConfig,
})
} else {
// Compare depths - prefer deeper nesting (more parent context)
const existingDepth = getComponentDepth(existingConfig.configTree, childComponent)
if (newDepth > existingDepth) {
// New config has more parent context, prefer it for breadcrumbs
componentConfigs.set(childComponent, {
configTree,
configOwner: component.name,
allComponents: allComponentsInConfig,
})
}
}
}
}
} catch (error) {
logger.error(`Error parsing page-navigation for ${component.name}: ${error.message}`)
}
}
}
}
// Step 1.5: Find the home component's config for breadcrumb calculations
// Home has the full hierarchy, so use it for ALL breadcrumb computations
const homeConfig = componentConfigs.get('home')
// Step 2: Apply navigation to all pages of components mentioned in configs
for (const page of pages) {
if (!page.src) continue
const configData = componentConfigs.get(page.src.component)
if (!configData) continue // No config for this component
try {
// Filter config tree to show only relevant hierarchy for current page
const relevantTree = filterConfigTreeForComponent(configData.configTree, page.src.component)
// Check if this component has children and owns the config
const hasChildComponents = componentHasChildren(configData.configTree, page.src.component)
const ownsConfig = configData.configOwner === page.src.component
// If component has children and owns config, we'll show its nav items outside buckets
// So build buckets only for children, not for the parent itself
let treeForBuckets = relevantTree
if (hasChildComponents && ownsConfig) {
// Extract children from ORIGINAL configTree (not filtered relevantTree)
// because filterConfigTreeForComponent sets children: undefined on parent
const parentNode = findNodeInTree(configData.configTree, page.src.component)
if (parentNode && parentNode.children) {
treeForBuckets = parentNode.children
} else {
logger.warn(`${page.src.component}: Has children but couldn't find them in configTree`)
}
}
const buckets = buildBucketsFromConfig(
treeForBuckets,
contentCatalog,
page.src.component,
page.src.version,
publishedUrlsSet,
componentVersionNavMap,
page
)
// Always compute breadcrumb hierarchy from HOME config (which has full hierarchy)
// This ensures components like self-managed show "Docs > Data Platform > Self-Managed"
// instead of just "Docs > Self-Managed"
page.asciidoc = page.asciidoc || {}
page.asciidoc.attributes = page.asciidoc.attributes || {}
if (homeConfig) {
const hierarchy = findComponentPath(homeConfig.configTree, page.src.component, contentCatalog)
if (hierarchy) {
page.asciidoc.attributes['page-breadcrumb-hierarchy'] = JSON.stringify(hierarchy)
}
}
// Only set custom navigation if buckets exist AND component is not standalone
// Standalone components appear at root level with no children (e.g., agentic-data-plane)
// They appear in product switcher/home nav but use standard Antora nav on their own pages
const isStandaloneComponent = isStandalone(configData.configTree, page.src.component)
if (buckets.length > 0 && !isStandaloneComponent) {
// Build full navigation array
let fullNavigation = []
// If component has children and owns config, show its nav items outside buckets
if (hasChildComponents && ownsConfig) {
// Get the component's own nav items (from nav.adoc) to prepend before child buckets
const compData = componentVersionNavMap.get(page.src.component)
let ownNavItems = []
if (compData) {
const versionData = compData.versionMap.get(page.src.version)
if (versionData && versionData.navigation) {
ownNavItems = filterUnpublishedPages(versionData.navigation, publishedUrlsSet)
}
}
// If parent component has its own nav items, wrap them in a pseudo-bucket with showNavItemsOnly
// This renders the items directly without bucket header/wrapper
if (ownNavItems.length > 0) {
fullNavigation.push({
items: ownNavItems,
showNavItemsOnly: true,
isCurrentBucket: true,
})
}
}
// Add child component buckets
fullNavigation = fullNavigation.concat(buckets)
page.asciidoc.attributes['page-custom-navigation'] = JSON.stringify(fullNavigation)
page.asciidoc.attributes['page-has-custom-nav'] = 'true'
// Set is-umbrella-nav to show parent bucket headers
page.asciidoc.attributes['page-is-umbrella-nav'] = 'true'
}
configDrivenPages.add(page.src.component)
} catch (error) {
logger.error(`Error processing navigation for ${page.src.path}: ${error.message}`)
}
}
if (configDrivenPages.size > 0) {
logger.info(`Processed config-driven navigation for ${configDrivenPages.size} components: ${Array.from(configDrivenPages).join(', ')}`)
}
} catch (error) {
logger.error(`Error building unified navigation: ${error.message}`)
logger.error(error.stack)
}
})
}
/**
* Build a map of component navigation data for quick lookup
* @param {Array} components - Array of component objects from content catalog
* @returns {Map} Map of component name -> { component, versionMap, latestVersion }
*/
function buildComponentVersionNavMap(components) {
const map = new Map()
for (const component of components) {
const versionMap = new Map()
for (const version of component.versions) {
versionMap.set(version.version, {
navigation: version.navigation || [],
displayVersion: version.displayVersion || version.version,
})
}
map.set(component.name, {
component,
versionMap,
latestVersion: component.latestVersion || component.versions[0],
})
}
return map
}
/**
* Filter navigation items to exclude unpublished pages
* @param {Array} items - Navigation items array
* @param {Map} publishedUrlsSet - Set of published page URLs
* @returns {Array} Filtered navigation items
*/
function filterUnpublishedPages(items, publishedUrlsSet) {
if (!Array.isArray(items)) return []
return items
.map((item) => {
// If item has a URL, check if it's in the published URLs set
if (item.url) {
// Skip items whose pages aren't published
if (!publishedUrlsSet.has(item.url)) {
return null
}
}
// Return a new object with filtered children to avoid mutating the original
if (item.items && Array.isArray(item.items)) {
return { ...item, items: filterUnpublishedPages(item.items, publishedUrlsSet) }
}
return { ...item }
})
.filter(Boolean) // Remove null entries
}
/**
* Extract component-metadata from a component version
* @param {Object} version - Component version object
* @returns {Object|null} The component-metadata object or null
*/
function getHeaderData(version) {
if (!version || !version.asciidoc || !version.asciidoc.attributes) {
return null
}
return version.asciidoc.attributes['component-metadata'] || null
}
/**
* Find a node in the config tree by name
* @param {Array} tree - Navigation config tree
* @param {string} componentName - Component name to search for
* @returns {Object|null} The node or null if not found
*/
function findNodeInTree(tree, componentName) {
if (!Array.isArray(tree)) return null
for (const item of tree) {
if (item.name === componentName) {
return item
}
if (item.children) {
const found = findNodeInTree(item.children, componentName)
if (found) return found
}
}
return null
}
/**
* Check if a component has children in the config tree
* @param {Array} tree - Navigation config tree
* @param {string} componentName - Component name to search for
* @returns {boolean} True if component has children
*/
function componentHasChildren(tree, componentName) {
const node = findNodeInTree(tree, componentName)
return !!(node && node.children && node.children.length > 0)
}
/**
* Find the depth of a component in a config tree
* @param {Array} tree - Navigation config tree
* @param {string} targetComponent - Component name to find
* @param {number} currentDepth - Current depth in recursion
* @returns {number} Depth of component (0 = root level), or -1 if not found
*/
function getComponentDepth(tree, targetComponent, currentDepth = 0) {
for (const item of tree) {
if (item.name === targetComponent) {
return currentDepth
}
if (item.children) {
const childDepth = getComponentDepth(item.children, targetComponent, currentDepth + 1)
if (childDepth !== -1) return childDepth
}
}
return -1 // Not found
}
/**
* Parse page-navigation YAML config into component tree structure
* @param {string|array} navConfig - YAML string or parsed array from page-navigation attribute
* @returns {Array} Array of bucket definitions with hierarchy
*
* Example input:
* - data-platform:
* - cloud-data-platform
* - self-managed: [streaming, connect]
* - redpanda-adp
*
* Example output:
* [{
* name: 'data-platform',
* children: [
* { name: 'cloud-data-platform' },
* { name: 'self-managed', children: [{ name: 'streaming' }, { name: 'connect' }] }
* ]
* }, { name: 'redpanda-adp' }]
*/
function parseNavigationConfig(navConfig) {
// If it's already an array (from YAML parsing), use it directly
let config
if (typeof navConfig === 'string') {
try {
config = yaml.load(navConfig)
} catch (error) {
// Log YAML parsing error with context
console.error(`Failed to parse page-navigation YAML: ${error.message}`)
console.error(`YAML content: ${navConfig}`)
return []
}
} else {
config = navConfig
}
if (!Array.isArray(config)) {
return []
}
function parseItem(item) {
if (typeof item === 'string') {
// Simple component name
return { name: item }
} else if (typeof item === 'object' && item !== null) {
// Object with component name as key and children as value
const keys = Object.keys(item)
if (keys.length === 0) return null
const name = keys[0]
const childrenValue = item[name]
if (!childrenValue) {
return { name }
}
// Parse children (can be array of strings, objects, or mix)
const children = Array.isArray(childrenValue)
? childrenValue.map(parseItem).filter(Boolean)
: [parseItem(childrenValue)].filter(Boolean)
return { name, children: children.length > 0 ? children : undefined }
}
return null
}
return config.map(parseItem).filter(Boolean)
}
/**
* Build navigation buckets from parsed config tree
* @param {Array} configTree - Parsed navigation config tree
* @param {Object} contentCatalog - Antora content catalog
* @param {string} currentPageComponent - Current page's component name
* @param {string} currentPageVersion - Current page's version
* @param {Set} publishedUrlsSet - Set of published page URLs
* @param {Map} componentVersionNavMap - Map of component navigation data
* @returns {Array} Array of bucket objects for templates
*/
/**
* Check if a component is standalone (root-level with no children)
* Standalone components appear in product switcher but use standard Antora nav
* @param {Array} configTree - Parsed navigation config tree
* @param {string} componentName - Component name to check
* @returns {boolean} True if component is standalone
*/
function isStandalone(configTree, componentName) {
for (const item of configTree) {
if (item.name === componentName) {
// Component found at root level
return !item.children || item.children.length === 0
}
}
return false
}
/**
* Filter config tree to show only relevant hierarchy for a component
* - If component is at root level: return entire tree
* - If component is a parent (has children): return that parent with its children
* - If component is a child (leaf): return parent + siblings (parent marked for nav-only display)
* @param {Array} configTree - Full navigation config tree
* @param {string} componentName - Current page component
* @returns {Array} Filtered config tree
*/
function filterConfigTreeForComponent(configTree, componentName) {
// Check if component is at root level
for (const item of configTree) {
if (item.name === componentName) {
// Component is at root - if it has children, show parent nav items + children as buckets
if (item.children) {
// Return parent with showNavItemsOnly flag + children as separate buckets
return [{ ...item, showNavItemsOnly: true, children: undefined }, ...item.children]
}
return configTree
}
}
// Component is nested - find it and determine if it's a parent or child
function findComponentAndParent(items, parent = null) {
for (const item of items) {
if (item.name === componentName) {
// Found the component
if (item.children) {
// Component is a parent - return it with its children
return { isParent: true, parent: null, result: [item] }
} else {
// Component is a leaf child
// Check if there are other leaf siblings (to determine if we should show siblings)
const siblings = parent ? parent.children.filter(child => child.name !== componentName) : []
const hasLeafSiblings = siblings.some(sibling => !sibling.children)
if (hasLeafSiblings) {
// Has other leaf siblings - show parent nav + all siblings (like Streaming/Connect)
// Mark parent as "showNavItemsOnly" so it doesn't render as a bucket header
return { isParent: false, parent: parent, result: parent ? [{ ...parent, showNavItemsOnly: true }, ...parent.children] : [item] }
} else {
// No leaf siblings (standalone like Cloud) - return empty to use standard nav
return { isParent: false, parent: null, result: [] }
}
}
}
if (item.children) {
const found = findComponentAndParent(item.children, item)
if (found) return found
}
}
return null
}
const found = findComponentAndParent(configTree)
return found ? found.result : configTree // Fallback to full tree if not found
}
function buildBucketsFromConfig(
configTree,
contentCatalog,
currentPageComponent,
currentPageVersion,
publishedUrlsSet,
componentVersionNavMap,
page
) {
// Get list of buckets to expand by default from page attribute
const expandBucketsAttr = page?.asciidoc?.attributes?.['page-expand-buckets'] || ''
const expandBuckets = expandBucketsAttr.split(',').map((s) => s.trim()).filter(Boolean)
function buildBucket(configItem) {
const componentName = configItem.name
const component = contentCatalog.getComponent(componentName)
if (!component) {
return null // Component not found
}
const latestVersion = component.latestVersion || component.versions[0]
if (!latestVersion) {
return null
}
// Get metadata from component-metadata
const metadata = getHeaderData(latestVersion)
if (!metadata) {
return null
}
// Build versions array
const versions = component.versions.map((v) => ({
version: v.version,
displayVersion: v.displayVersion || v.version,
url: v.url,
isPrerelease: !!v.prerelease,
releaseDate: v.asciidoc?.attributes?.['page-release-date'] || null,
isEol: v.asciidoc?.attributes?.['page-is-past-eol'] === 'true',
}))
// Determine if this is the current bucket
const isCurrentBucket = componentName === currentPageComponent
// Check if this bucket should be expanded by default (from page attribute)
const isExpandedByDefault = expandBuckets.includes(componentName)
// Get navigation for this component
let navigation, displayVersion
const compData = componentVersionNavMap.get(componentName)
if (isCurrentBucket && compData) {
// For current bucket, use page's version
const versionData = compData.versionMap.get(currentPageVersion)
if (versionData) {
navigation = versionData.navigation
displayVersion = versionData.displayVersion
} else {
navigation = compData.latestVersion?.navigation || []
displayVersion = compData.latestVersion?.displayVersion || compData.latestVersion?.version
}
} else if (compData) {
// For other buckets, use latest version
navigation = compData.latestVersion?.navigation || []
displayVersion = compData.latestVersion?.displayVersion || compData.latestVersion?.version
} else {
navigation = []
displayVersion = latestVersion.displayVersion || latestVersion.version
}
// Filter unpublished pages
const filteredNavigation = filterUnpublishedPages(navigation, publishedUrlsSet)
const bucket = {
componentName,
title: metadata.title || component.title || componentName,
color: metadata.color || '#6366f1',
icon: metadata.icon || null,
order: metadata.order || 999,
versions,
currentVersion: displayVersion,
isCurrentBucket,
isExpandedByDefault,
items: filteredNavigation,
componentUrl: component.url,
showNavItemsOnly: configItem.showNavItemsOnly || false, // Flag for rendering nav items without bucket header,
}
// Process children recursively
if (configItem.children && configItem.children.length > 0) {
bucket.children = configItem.children.map(buildBucket).filter(Boolean)
// Determine if parent should be expanded
bucket.hasCurrentChild = bucket.children.some((c) => c.isCurrentBucket || c.hasCurrentChild)
}
return bucket
}
return configTree.map(buildBucket).filter(Boolean)
}
/**
* Find component's position in navigation hierarchy for breadcrumbs
* @param {Array} configTree - Parsed navigation config tree
* @param {string} targetComponent - Component to find
* @param {Object} contentCatalog - Antora content catalog
* @returns {Array|null} Array of breadcrumb items or null
*/
function findComponentPath(configTree, targetComponent, contentCatalog) {
function search(items, path = []) {
for (const item of items) {
const componentName = item.name
const component = contentCatalog.getComponent(componentName)
if (!component) continue
const latestVersion = component.latestVersion || component.versions[0]
const metadata = latestVersion ? getHeaderData(latestVersion) : null
const breadcrumbItem = {
component: componentName,
title: metadata?.title || component.title || componentName,
url: component.url || `/${componentName}/`,
}
if (componentName === targetComponent) {
return [...path, breadcrumbItem]
}
if (item.children && item.children.length > 0) {
const childPath = search(item.children, [...path, breadcrumbItem])
if (childPath) return childPath
}
}
return null
}
return search(configTree)
}