@redpanda-data/docs-extensions-and-macros
Version:
Antora extensions and macros developed for Redpanda documentation.
298 lines (274 loc) • 11.8 kB
JavaScript
/**
* Stub reconciler for consumer repos that publish rpk plugin docs through
* single-source stubs (adp-docs for rpk ai).
*
* The docs repo owns the generated partials; consumer repos own one static
* stub page per command plus a nav entry. When a plugin release adds or
* removes commands, the stubs drift: a new partial has no stub (command
* invisible on the consumer site) and a deleted partial leaves a stub with an
* unresolved include (broken page). This module reconciles the stub set
* against the current partials rather than applying a diff, so it also heals
* pre-existing drift and is idempotent.
*/
const fs = require('fs')
const path = require('path')
const os = require('os')
const { spawnSync } = require('child_process')
/**
* Read command titles from generated partials. The title line is
* authoritative: dashified filenames cannot be reversed unambiguously
* (rpk-ai-llm-provider could be `llm provider` or `llm-provider`).
* @param {string} partialsDir - Directory of generated .adoc partials
* @returns {Array<{file: string, title: string, description: string|undefined}>} Sorted by command path
*/
function readPartialTitles(partialsDir) {
const partials = []
for (const file of fs.readdirSync(partialsDir)) {
if (!file.endsWith('.adoc')) continue
const content = fs.readFileSync(path.join(partialsDir, file), 'utf8')
const match = content.match(/^= (.+)$/m)
if (!match) {
console.warn(`Warning: no title line in ${file}; skipping`)
continue
}
// The partial repeats :description: inside its single-source tag, but
// Antora resolves page metadata with a header-only parse that never sees
// the include — the stub must carry the description in its own header.
const description = (content.match(/^:description:[ \t]*(.+)$/m) || [])[1]
partials.push({ file, title: match[1].trim(), description: description && description.trim() })
}
// Hierarchical order: sort by command words so parents precede children
partials.sort((a, b) => {
const aw = a.title.split(' ')
const bw = b.title.split(' ')
for (let i = 0; i < Math.max(aw.length, bw.length); i++) {
if (aw[i] === bw[i]) continue
if (aw[i] === undefined) return -1
if (bw[i] === undefined) return 1
return aw[i] < bw[i] ? -1 : 1
}
return 0
})
return partials
}
/**
* Sparse-clone the docs repo and return the path to a plugin's partials dir.
* @param {Object} params
* @param {string} params.docsRepo - owner/repo (e.g. redpanda-data/docs)
* @param {string} params.docsRef - Branch or tag to read (e.g. main)
* @param {string} params.plugin - Plugin command name (e.g. ai)
* @returns {string} Local path to the partials directory
*/
function fetchPartialsDir({ docsRepo, docsRef, plugin, sourcePath }) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plugin-stubs-'))
const repoDir = path.join(tmpDir, 'docs')
// ai and cloud content renders as partials; connect renders as pages —
// both carry the single-source tag, so either family can be stubbed
const sparsePath = sourcePath || `modules/reference/partials/rpk-${plugin}`
console.log(`Fetching ${sparsePath} from ${docsRepo}@${docsRef}...`)
const cloneResult = spawnSync('git', [
'clone', '--depth', '1', '--filter=blob:none', '--sparse',
'--branch', docsRef,
`https://github.com/${docsRepo}.git`, repoDir
], { encoding: 'utf8', timeout: 180000 })
if (cloneResult.status !== 0) {
throw new Error(`Failed to clone ${docsRepo}@${docsRef}: ${cloneResult.stderr}`)
}
const sparseResult = spawnSync('git', ['sparse-checkout', 'set', sparsePath], {
cwd: repoDir, encoding: 'utf8', timeout: 60000
})
if (sparseResult.status !== 0) {
throw new Error(`Failed sparse checkout of ${sparsePath}: ${sparseResult.stderr}`)
}
const partialsDir = path.join(repoDir, sparsePath)
if (!fs.existsSync(partialsDir)) {
throw new Error(`Partials directory not found in ${docsRepo}@${docsRef}: ${sparsePath}`)
}
return partialsDir
}
/**
* Infer the include prefix from an existing managed stub, so the reconciler
* follows whatever component/module coordinates the consumer repo uses.
* @param {string} stubDir - Consumer repo stub directory
* @param {string} plugin - Plugin command name
* @returns {string|null} e.g. "streaming:reference:partial$rpk-ai/"
*/
function inferIncludePrefix(stubDir, plugin) {
if (!fs.existsSync(stubDir)) return null
for (const file of fs.readdirSync(stubDir)) {
if (!file.endsWith('.adoc')) continue
const content = fs.readFileSync(path.join(stubDir, file), 'utf8')
const match = content.match(new RegExp(`include::([^\\[]*(?:partial|page)\\$[^\\[]*rpk-${plugin}/)`))
if (match) return match[1]
}
return null
}
/**
* Render a stub page.
* @param {Object} params
* @param {string} params.title - Command path (e.g. "rpk ai auth login")
* @param {string} params.file - Partial filename
* @param {string} [params.description] - Meta description from the partial header
* @param {string} params.includePrefix - Antora resource prefix
* @param {Array<string>} params.attributes - Page attribute lines
* @returns {string}
*/
function renderStub({ title, file, description, includePrefix, attributes }) {
const lines = [`= ${title}`]
if (description) lines.push(`:description: ${description}`)
for (const attr of attributes) lines.push(attr)
lines.push('')
lines.push(`include::${includePrefix}${file}[tag=single-source]`)
lines.push('')
return lines.join('\n')
}
/**
* Reconcile a consumer repo's stub pages and nav section against the
* current set of generated partials.
* @param {Object} params
* @param {Array<{file: string, title: string}>} params.partials
* @param {string} params.stubDir - Consumer stub directory (created if missing)
* @param {string} params.navFile - Consumer nav.adoc path
* @param {string} params.plugin - Plugin command name (e.g. ai)
* @param {string} params.includePrefix - Antora resource prefix for includes
* @param {Array<string>} [params.attributes] - Page attributes for new stubs
* @param {boolean} [params.dryRun]
* @returns {Object} { created, deleted, keptNonStub, navUpdated, renameCandidates }
*/
function reconcileStubs({
partials,
stubDir,
navFile,
plugin,
includePrefix,
attributes = [':page-preview: true'],
dryRun = false
}) {
const managedStubRe = new RegExp(`include::[^\\[]*(?:partial|page)\\$[^\\[]*rpk-${plugin}/([\\w.-]+\\.adoc)\\[`)
const partialByFile = new Map(partials.map(p => [p.file, p]))
fs.mkdirSync(stubDir, { recursive: true })
const existingStubs = fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc'))
const created = []
const deleted = []
const keptNonStub = []
const skippedAliasTargets = []
// Page names already claimed as aliases by other pages in this directory.
// Creating a page whose resource ID is an alias target makes the Antora
// build fatal ("Page alias cannot reference an existing page"), which
// happens when a rename alias exists here while the upstream partial for
// the old name still lingers. Skip those creations and surface them.
const aliasClaims = new Map()
for (const file of existingStubs) {
const content = fs.readFileSync(path.join(stubDir, file), 'utf8')
const aliasLine = content.match(/^:page-aliases:\s*(.+)$/m)
if (!aliasLine) continue
for (const target of aliasLine[1].split(',')) {
const base = target.trim().split('/').pop()
if (base) aliasClaims.set(base, file)
}
}
// Delete managed stubs whose partial no longer exists. Pages that do not
// match the managed-stub shape are never deleted: they may be hand-written.
const deletedTitles = new Map()
for (const file of existingStubs) {
const stubPath = path.join(stubDir, file)
const content = fs.readFileSync(stubPath, 'utf8')
const match = content.match(managedStubRe)
if (!match) {
if (!partialByFile.has(file)) keptNonStub.push(file)
continue
}
if (!partialByFile.has(match[1])) {
const titleMatch = content.match(/^= (.+)$/m)
deletedTitles.set(file, titleMatch ? titleMatch[1].trim() : '')
if (!dryRun) fs.unlinkSync(stubPath)
deleted.push(file)
}
}
// Create stubs for partials that have none
const remainingStubs = new Set(
fs.existsSync(stubDir) ? fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc')) : []
)
for (const partial of partials) {
if (remainingStubs.has(partial.file)) continue
if (aliasClaims.has(partial.file)) {
skippedAliasTargets.push({ file: partial.file, claimedBy: aliasClaims.get(partial.file) })
continue
}
if (!dryRun) {
fs.writeFileSync(
path.join(stubDir, partial.file),
renderStub({ ...partial, includePrefix, attributes }),
'utf8'
)
}
created.push(partial.file)
}
// Rename candidates: same parent command, same depth, related last words
// (llm -> llm-provider). Proposed for the reviewer, who decides whether
// the new stub gets a page alias.
const renameCandidates = []
for (const [dFile, dTitle] of deletedTitles) {
if (!dTitle) continue
const dWords = dTitle.split(' ')
for (const cFile of created) {
const cTitle = (partialByFile.get(cFile) || {}).title || ''
const cWords = cTitle.split(' ')
if (cWords.length !== dWords.length) continue
if (cWords.slice(0, -1).join(' ') !== dWords.slice(0, -1).join(' ')) continue
const dLast = dWords[dWords.length - 1]
const cLast = cWords[cWords.length - 1]
if (cLast.startsWith(dLast) || dLast.startsWith(cLast)) {
renameCandidates.push({ deleted: dFile, created: cFile })
}
}
}
// Rebuild the plugin's nav block: keep the labeled parent line, regenerate
// child entries from titles at star depth = parent depth + (words - 2)
let navUpdated = false
if (navFile && fs.existsSync(navFile)) {
const navLines = fs.readFileSync(navFile, 'utf8').split('\n')
const parentRe = new RegExp(`^(\\*+) xref:[^\\[]*rpk-${plugin}/rpk-${plugin}\\.adoc\\[`)
const parentIdx = navLines.findIndex(l => parentRe.test(l))
if (parentIdx === -1) {
console.warn(`Warning: no rpk-${plugin} parent entry found in ${navFile}; nav not updated`)
} else {
const parentStars = navLines[parentIdx].match(parentRe)[1].length
const navPathPrefix = navLines[parentIdx].match(/xref:([^\[]*rpk-\w+\/)/)[1]
// The block ends at the first line that is not a deeper entry
let end = parentIdx + 1
while (end < navLines.length) {
const starMatch = navLines[end].match(/^(\*+) /)
if (!starMatch || starMatch[1].length <= parentStars) break
end++
}
const skippedFiles = new Set(skippedAliasTargets.map(t => t.file))
const entries = []
for (const partial of partials) {
if (skippedFiles.has(partial.file)) continue
const words = partial.title.split(' ').length
if (words <= 2) continue // the parent line represents the root command
const stars = '*'.repeat(parentStars + (words - 2))
entries.push(`${stars} xref:${navPathPrefix}${partial.file}[]`)
}
const rebuilt = [
...navLines.slice(0, parentIdx + 1),
...entries,
...navLines.slice(end)
].join('\n')
if (rebuilt !== navLines.join('\n')) {
if (!dryRun) fs.writeFileSync(navFile, rebuilt, 'utf8')
navUpdated = true
}
}
}
return { created, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
}
module.exports = {
readPartialTitles,
fetchPartialsDir,
inferIncludePrefix,
renderStub,
reconcileStubs
}