@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
237 lines (200 loc) • 9.74 kB
JavaScript
/* Example use in the playbook
* antora:
extensions:
* - require: ./extensions/process-context-switcher.js
*
* This extension processes the `page-context-switcher` attribute and:
* 1. Replaces "current" references with the full resource ID of the current page
* 2. Injects context switchers into target pages with proper bidirectional linking
* 3. Automatically adds the current page's version to resource IDs that don't specify one
*
* Example context switcher attribute:
* :page-context-switcher: [{"name": "Version 2.x", "to": "ROOT:console:config/security/authentication.adoc"}, {"name": "Version 3.x", "to": "current"}]
*
* Note: You can omit the version from resource IDs - the extension will automatically
* use the current page's version. So "ROOT:console:file.adoc" becomes "current@ROOT:console:file.adoc"
*/
;
module.exports.register = function ({ config }) {
const logger = this.getLogger('context-switcher-extension');
this.on('documentsConverted', async ({ contentCatalog }) => {
// Find all pages with context-switcher attribute
const pages = contentCatalog.findBy({ family: 'page' });
const pagesToProcess = pages.filter(page =>
page.asciidoc.attributes['page-context-switcher']
);
if (pagesToProcess.length === 0) {
logger.debug('No pages found with page-context-switcher attribute');
return;
}
logger.info(`Processing context-switcher attribute for ${pagesToProcess.length} pages`);
// Process each page with context-switcher
for (const page of pagesToProcess) {
processContextSwitcher(page, contentCatalog, logger);
}
});
/**
* Process the context-switcher attribute for a page
* @param {Object} page - The page object
* @param {Object} contentCatalog - The content catalog
* @param {Object} logger - Logger instance
*/
function processContextSwitcher(page, contentCatalog, logger) {
const contextSwitcherAttr = page.asciidoc.attributes['page-context-switcher'];
try {
// Parse the JSON attribute
const contextSwitcher = JSON.parse(contextSwitcherAttr);
if (!Array.isArray(contextSwitcher)) {
logger.warn(`Invalid context-switcher format in ${page.src.path}: expected array`);
return;
}
// Get current page's full resource ID
const currentResourceId = buildResourceId(page);
logger.debug(`Processing context-switcher for page: ${currentResourceId}`);
// Make a copy for processing target pages (before modifying "current")
const originalContextSwitcher = JSON.parse(JSON.stringify(contextSwitcher));
// Track if we made any changes
let hasChanges = false;
// Process each context switcher item
for (let item of contextSwitcher) {
if (item.to === 'current') {
item.to = currentResourceId;
hasChanges = true;
logger.debug(`Replaced 'current' with '${currentResourceId}' in context-switcher`);
} else if (item.to !== currentResourceId) {
// For non-current items, find and update the target page
const targetPage = findPageByResourceId(item.to, contentCatalog, page);
if (targetPage) {
injectContextSwitcherToTargetPage(targetPage, originalContextSwitcher, currentResourceId, logger);
} else {
logger.warn(`Target page not found for resource ID: '${item.to}'. Check that the component, module, and path exist. Enable debug logging to see available pages.`);
}
}
}
// Update the current page's attribute if we made changes
if (hasChanges) {
page.asciidoc.attributes['page-context-switcher'] = JSON.stringify(contextSwitcher);
}
} catch (error) {
logger.error(`Error parsing context-switcher attribute in ${page.src.path}: ${error.message}`);
}
}
/**
* Build a full resource ID for a page (component:module:relative-path)
* @param {Object} page - The page object
* @returns {string} The full resource ID
*/
function buildResourceId(page) {
const component = page.src.component;
const version = page.src.version;
const module = page.src.module || 'ROOT';
const relativePath = page.src.relative;
// Format: version@component:module:relative-path
return `${version}@${component}:${module}:${relativePath}`;
}
/**
* Normalize a resource ID by adding the current page's version if missing
* @param {string} resourceId - The resource ID to normalize
* @param {Object} currentPage - The current page (for context)
* @returns {string} The normalized resource ID
*/
function normalizeResourceId(resourceId, currentPage) {
// Sanitize input to avoid syntax errors
if (!resourceId || typeof resourceId !== 'string') {
throw new Error('Resource ID must be a non-empty string');
}
if (!currentPage || !currentPage.src || !currentPage.src.version) {
throw new Error('Current page must have a valid src.version property');
}
// Trim whitespace and remove any dangerous characters
const sanitizedResourceId = resourceId.trim();
if (!sanitizedResourceId) {
throw new Error('Resource ID cannot be empty or whitespace-only');
}
// Validate basic resource ID format (component:module:path or version@component:module:path)
if (!/^([^@]+@)?[^:]+:[^:]+:.+$/.test(sanitizedResourceId)) {
throw new Error(`Invalid resource ID format: '${sanitizedResourceId}'. Expected format: [version@]component:module:path`);
}
// If the resource ID already contains a version (has @), return as-is
if (sanitizedResourceId.includes('@')) {
return sanitizedResourceId;
}
// Add the current page's version to the resource ID
const currentVersion = currentPage.src.version;
return `${currentVersion}@${sanitizedResourceId}`;
}
/**
* Find a page by its resource ID using Antora's built-in resolution
* @param {string} resourceId - The resource ID to find
* @param {Object} contentCatalog - The content catalog
* @param {Object} currentPage - The current page (for context)
* @returns {Object|null} The found page or null
*/
function findPageByResourceId(resourceId, contentCatalog, currentPage) {
try {
// Normalize the resource ID by adding version if missing
const normalizedResourceId = normalizeResourceId(resourceId, currentPage);
if (normalizedResourceId !== resourceId) {
logger.debug(`Normalized resource ID '${resourceId}' to '${normalizedResourceId}' using current page version`);
}
try {
// Use Antora's built-in resource resolution
const resource = contentCatalog.resolveResource(normalizedResourceId, currentPage.src);
if (resource) {
logger.debug(`Resolved resource ID '${normalizedResourceId}' to: ${buildResourceId(resource)}`);
return resource;
} else {
logger.warn(`Could not resolve resource ID: '${normalizedResourceId}'. Check that the component, module, and path exist.`);
// Provide some debugging help by showing available pages in the current component
const currentComponentPages = contentCatalog.findBy({
family: 'page',
component: currentPage.src.component
}).slice(0, 10);
logger.debug(`Available pages in current component '${currentPage.src.component}' (first 10):`,
currentComponentPages.map(p => `${p.src.version}@${p.src.component}:${p.src.module || 'ROOT'}:${p.src.relative}`));
return null;
}
} catch (error) {
logger.debug(`Error resolving resource ID '${normalizedResourceId}': ${error.message}`);
return null;
}
} catch (error) {
// Handle normalization errors (invalid resource ID format)
logger.warn(`Invalid resource ID '${resourceId}': ${error.message}`);
return null;
}
}
/**
* Inject context-switcher attribute to target page
* @param {Object} targetPage - The target page to inject to
* @param {Array} contextSwitcher - The context switcher configuration
* @param {string} currentPageResourceId - The current page's resource ID
* @param {Object} logger - Logger instance
*/
function injectContextSwitcherToTargetPage(targetPage, contextSwitcher, currentPageResourceId, logger) {
// Check if target page already has context-switcher attribute
if (targetPage.asciidoc.attributes['page-context-switcher']) {
logger.info(`Target page ${buildResourceId(targetPage)} already has context-switcher attribute. Skipping injection to avoid overwriting existing configuration: ${targetPage.asciidoc.attributes['page-context-switcher']}`);
return;
}
const targetPageResourceId = buildResourceId(targetPage);
logger.debug(`Injecting context switcher to target page: ${targetPageResourceId}`);
// Create a copy of the context switcher for the target page
// Simply replace "current" with the original page's resource ID
const targetContextSwitcher = contextSwitcher.map(item => {
if (item.to === 'current') {
logger.debug(`Replacing 'current' with original page: ${currentPageResourceId}`);
return {
...item,
to: currentPageResourceId
};
}
// All other items stay the same
return { ...item };
});
logger.debug(`Target context switcher:`, JSON.stringify(targetContextSwitcher, null, 2));
// Inject the attribute
targetPage.asciidoc.attributes['page-context-switcher'] = JSON.stringify(targetContextSwitcher);
logger.debug(`Successfully injected context-switcher to target page: ${targetPageResourceId}`);
}
};