@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
306 lines (254 loc) • 10.6 kB
JavaScript
const fs = require('fs')
const path = require('path')
const os = require('os')
const handlebars = require('handlebars')
// Import and register Redpanda Connect helpers
const helpers = require('../tools/redpanda-connect/helpers')
Object.entries(helpers).forEach(([name, fn]) => {
if (typeof fn === 'function') {
handlebars.registerHelper(name, fn)
}
})
// Register table format helper
handlebars.registerHelper('renderConnectFieldsTable', function (children) {
if (!children || !Array.isArray(children) || children.length === 0) {
return 'No configuration fields available.\n\n'
}
const rows = []
function collectFields (fieldsList, pathPrefix = '') {
if (!Array.isArray(fieldsList)) return
fieldsList.forEach(field => {
if (field.is_deprecated || !field.name) return
const isArray = field.kind === 'array'
const nameWithArray = (typeof field.name === 'string' && isArray && !field.name.endsWith('[]'))
? `${field.name}[]`
: field.name
const currentPath = pathPrefix ? `${pathPrefix}.${nameWithArray}` : nameWithArray
// Normalize type
let displayType
const isArrayTitle = typeof field.name === 'string' && field.name.endsWith('[]')
if (isArrayTitle) {
displayType = 'array<object>'
} else if (field.type === 'string' && field.kind === 'array') {
displayType = 'array'
} else if (field.type === 'unknown' && field.kind === 'map') {
displayType = 'object'
} else if (field.type === 'unknown' && (field.kind === 'array' || field.kind === 'list')) {
displayType = 'array'
} else {
displayType = field.type
}
// Format default value
let defaultValue = ''
if (field.default !== undefined) {
if (Array.isArray(field.default) && field.default.length === 0) {
defaultValue = '`[]`'
} else if (
field.default !== null &&
typeof field.default === 'object' &&
!Array.isArray(field.default) &&
Object.keys(field.default).length === 0
) {
defaultValue = '`{}`'
} else if (typeof field.default === 'string') {
const escaped = field.default.replace(/`/g, '\\`')
defaultValue = `\`${escaped}\``
} else if (typeof field.default === 'number' || typeof field.default === 'boolean') {
defaultValue = `\`${field.default}\``
} else if (field.default === null) {
defaultValue = '`null`'
} else {
defaultValue = '_(complex)_'
}
}
// Clean description for table (single line)
let desc = (field.description || '').replace(/\n+/g, ' ').trim()
if (desc.length > 150) {
desc = desc.substring(0, 147) + '...'
}
rows.push({
field: `\`${currentPath}\``,
type: `\`${displayType}\``,
default: defaultValue || '-',
description: desc || '-'
})
// Recurse for children
if (field.children && Array.isArray(field.children) && field.children.length > 0) {
collectFields(field.children, currentPath)
}
})
}
collectFields(children, '')
if (rows.length === 0) return 'No configuration fields available.\n\n'
let table = '[cols="2,1,1,4"]\n'
table += '|===\n'
table += '|Field |Type |Default |Description\n\n'
rows.forEach(row => {
table += `|${row.field}\n`
table += `|${row.type}\n`
table += `|${row.default}\n`
table += `|${row.description}\n\n`
})
table += '|===\n'
return new handlebars.SafeString(table)
})
// Default configuration
const DEFAULTS = {
format: 'nested', // 'nested' or 'table'
enabled: true // Allow disabling the extension
}
module.exports.register = function ({ config }) {
const logger = this.getLogger('generate-fields-only-pages-extension')
// Merge config with defaults
const {
format = DEFAULTS.format,
enabled = DEFAULTS.enabled
} = config || {}
if (!enabled) {
logger.info('Extension disabled via config')
return
}
// Validate format
if (format !== 'nested' && format !== 'table') {
logger.error(`Invalid format '${format}'. Must be 'nested' or 'table'. Disabling extension.`)
return
}
// Compile template based on format (without title - these pages are meant to be included)
const helperName = format === 'table' ? 'renderConnectFieldsTable' : 'renderConnectFields'
const fieldOnlyTemplate = handlebars.compile(`{{{${helperName} children}}}`)
this.on('contentClassified', ({ contentCatalog, siteCatalog }) => {
const component = contentCatalog.getComponent('connect')
if (!component) {
logger.warn('connect component not found. Skipping field-only page generation.')
return
}
const componentVersion = component.latest
if (!componentVersion) {
logger.warn('No latest version found for connect component.')
return
}
let connectorData
// Look for any versioned JSON attachment in the components module
// (i.e. modules/components/attachments/connect-X.Y.Z.json)
const attachments = contentCatalog.findBy({
component: 'connect',
version: componentVersion.version,
module: 'components',
family: 'attachment'
})
// Find all versioned connector JSON attachments and sort by semver
const versionedAttachmentPattern = /^connect-(\d+)\.(\d+)\.(\d+)\.json$/
const matchingAttachments = attachments
.map((file) => {
const relative = file.src?.relative || ''
const basename = relative.split('/').pop()
const match = versionedAttachmentPattern.exec(basename)
if (match) {
return {
file,
version: {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
string: `${match[1]}.${match[2]}.${match[3]}`
}
}
}
return null
})
.filter(Boolean)
.sort((a, b) => {
// Sort by major, then minor, then patch (descending)
if (a.version.major !== b.version.major) return b.version.major - a.version.major
if (a.version.minor !== b.version.minor) return b.version.minor - a.version.minor
return b.version.patch - a.version.patch
})
if (matchingAttachments.length > 1) {
const versions = matchingAttachments.map(m => m.version.string).join(', ')
logger.warn(`Multiple versioned connector JSON attachments found (${versions}). Using highest version: ${matchingAttachments[0].version.string}`)
}
const attachment = matchingAttachments[0]?.file
if (!attachment) {
logger.warn('No JSON attachment found in the components module of the connect content catalog. Skipping field-only page generation.')
return
}
try {
connectorData = JSON.parse(attachment.contents.toString('utf8'))
logger.info(`Loaded connector data from content catalog attachment: ${attachment.src?.relative || 'unknown'}`)
} catch (err) {
logger.error(`Failed to parse connector data from content catalog attachment: ${err.message}`)
return
}
let pagesGenerated = 0
// Get origin from first existing page in component (for git metadata)
const existingPages = contentCatalog.getPages((page) => page.src.component === 'connect')
const origin = existingPages.length > 0 ? existingPages[0].src.origin : { type: 'generated' }
// Iterate over each type (inputs, outputs, processors, etc.)
for (const [type, items] of Object.entries(connectorData)) {
if (!Array.isArray(items)) continue
// Skip bloblang functions/methods (they don't have config fields)
if (type === 'bloblang-functions' || type === 'bloblang-methods') continue
for (const item of items) {
if (!item.name) continue
// Only generate if there are fields
const hasFields = (item.config && item.config.children && item.config.children.length > 0) ||
(item.children && item.children.length > 0)
if (!hasFields) continue
const fields = item.config?.children || item.children || []
try {
// Use Handlebars template to render content
const content = fieldOnlyTemplate({
name: item.name,
children: fields
})
const typeDir = type.endsWith('s') ? type : `${type}s`
const relative = `fields/${typeDir}/${item.name}.adoc`
// Create a fake absolute path for generated files (used by logger)
const fakeAbspath = path.join(os.tmpdir(), 'generated-fields-only', relative)
// Create a stat object like real files have
const contentBuffer = Buffer.from(content)
const stat = Object.assign(new fs.Stats(), {
mode: 0o100644,
mtime: new Date(),
size: contentBuffer.byteLength
})
// Create file spec with all required properties
const file = contentCatalog.addFile({
path: `modules/components/pages/${relative}`,
contents: contentBuffer,
stat: stat,
src: {
component: 'connect',
version: componentVersion.version,
module: 'components',
family: 'page',
relative: relative,
mediaType: 'text/asciidoc',
origin: origin,
abspath: fakeAbspath // Needed by logger for error messages
}
})
// Mark as field-only page (used by convert-to-markdown and add-llms-directive to skip directive)
file.isFieldOnlyPage = true
pagesGenerated++
} catch (err) {
logger.error(`Failed to generate field-only page for ${type}/${item.name}: ${err.message}`)
}
}
}
logger.info(`Generated ${pagesGenerated} field-only pages`)
})
// Unpublish field-only pages as HTML (they should only exist as markdown)
// Do this in beforePublish so pages go through full processing (composition, markdown conversion)
// but don't get written as HTML files
this.on('beforePublish', ({ contentCatalog }) => {
const fieldOnlyPages = contentCatalog.getPages((page) => page.isFieldOnlyPage === true && page.out)
fieldOnlyPages.forEach((page) => {
delete page.out
})
if (fieldOnlyPages.length > 0) {
logger.debug(`Unpublished ${fieldOnlyPages.length} field-only pages from HTML output`)
}
})
}