@redpanda-data/docs-extensions-and-macros 5.2.5 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -291,6 +291,24 @@ function shouldUsePartialDir(overrides, commandPath) {
291
291
  return false
292
292
  }
293
293
 
294
+ /**
295
+ * Check if a command belongs to the rpk cloud or rpk security secret families.
296
+ * Their pages are routed to the cloud partials directory (cloudSecretDir) and
297
+ * published by cloud-docs, so links to them from regular pages must cross to
298
+ * the cloud component.
299
+ * @param {string} commandPath - Full command path (e.g., "rpk cloud login")
300
+ * @returns {boolean}
301
+ */
302
+ function isCloudSecretCommand(commandPath) {
303
+ return commandPath === 'rpk cloud' || commandPath.startsWith('rpk cloud ') ||
304
+ commandPath === 'rpk security secret' || commandPath.startsWith('rpk security secret ')
305
+ }
306
+
307
+ /**
308
+ * Antora component that publishes the rpk cloud and rpk security secret pages.
309
+ */
310
+ const CLOUD_DOCS_COMPONENT = 'cloud-data-platform'
311
+
294
312
  /**
295
313
  * Get command metadata from overrides
296
314
  * @param {Object} overrides - Overrides object (resolved)
@@ -778,6 +796,76 @@ function convertIndentedCodeBlocksToAsciiDoc(text, codeBlockStore = []) {
778
796
  while (i < lines.length) {
779
797
  const line = lines[i]
780
798
 
799
+ // Unindented shell example: "$ rpk ..." at column 0 followed by its
800
+ // sample output on the contiguous lines below. Rendered as a command
801
+ // block plus a no-copy output block so the output's =-underlined titles
802
+ // and aligned columns are never parsed as prose or headings.
803
+ if (/^\$\s/.test(line)) {
804
+ const command = line.replace(/^\$\s+/, '')
805
+ let j = i + 1
806
+ const outputLines = []
807
+ while (j < lines.length && lines[j].trim() !== '' && !/^\$\s/.test(lines[j])) {
808
+ outputLines.push(lines[j])
809
+ j++
810
+ }
811
+ let block = `\n[,bash]\n----\n${command}\n----\n`
812
+ if (outputLines.length > 0) {
813
+ block += `\n[.no-copy]\n----\n${outputLines.join('\n')}\n----\n`
814
+ }
815
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
816
+ codeBlockStore.push(block)
817
+ result.push('')
818
+ result.push(placeholder)
819
+ result.push('')
820
+ i = j
821
+ continue
822
+ }
823
+
824
+ // Colon-introduced code sample: prose ending with ":" followed by an
825
+ // indented block that is not a list (Cedar policies, config snippets).
826
+ // Deeply indented (4+ space) blocks are literals by help-text convention
827
+ // even without a colon introducer (path templates like
828
+ // " kafka/{topic}/{partition}_{revision}/" would otherwise render as
829
+ // prose whose braces Asciidoctor eats as attribute references).
830
+ // Captured verbatim (dedented) so inline-code transforms never touch it.
831
+ const prevNonBlank = [...result].reverse().find(l => l.trim() !== '')
832
+ // Only a chunk that starts after a blank line is a standalone literal;
833
+ // a deeply indented line mid-chunk is a wrapped continuation of a table
834
+ // row or list item and belongs to the converters below.
835
+ const atChunkStart = result.length === 0 || result[result.length - 1].trim() === ''
836
+ if (
837
+ (
838
+ (prevNonBlank && /:\s*$/.test(prevNonBlank) && /^[ ]{2,}\S/.test(line)) ||
839
+ (atChunkStart && /^[ ]{4,}\S/.test(line))
840
+ ) &&
841
+ !/^[ ]{2,}(-|\*|\d+[.)])\s/.test(line) &&
842
+ !/^[ ]{2,}(--|rpk\s|\$\s)/.test(line)
843
+ ) {
844
+ const blockLines = []
845
+ let j = i
846
+ while (j < lines.length && (/^[ ]{2,}\S/.test(lines[j]) || lines[j].trim() === '')) {
847
+ if (lines[j].trim() === '' && (j + 1 >= lines.length || !/^[ ]{2,}\S/.test(lines[j + 1]))) break
848
+ blockLines.push(lines[j])
849
+ j++
850
+ }
851
+ // Column-aligned layouts (two or more lines with a run of spaces
852
+ // separating columns) are definition tables, not code: leave them for
853
+ // the indented-table/YAML converters below.
854
+ const alignedLines = blockLines.filter(l => /\S\s{2,}\S/.test(l.trim())).length
855
+ if (alignedLines < 2) {
856
+ const indent = Math.min(...blockLines.filter(l => l.trim() !== '').map(l => l.search(/\S/)))
857
+ const dedented = blockLines.map(l => l.slice(indent)).join('\n')
858
+ const codeBlock = `\n[,text]\n----\n${dedented}\n----\n`
859
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
860
+ codeBlockStore.push(codeBlock)
861
+ result.push('')
862
+ result.push(placeholder)
863
+ result.push('')
864
+ i = j
865
+ continue
866
+ }
867
+ }
868
+
781
869
  // Check for indented command example: 2+ spaces then --, rpk, or $ (shell prompt)
782
870
  // e.g. " --job-name test --labels ..."
783
871
  // e.g. " rpk cluster info"
@@ -1240,10 +1328,13 @@ function convertIndentedTablesToAsciiDoc(text, options = {}) {
1240
1328
  * @param {Object|null} customTransformations
1241
1329
  * @returns {string}
1242
1330
  */
1243
- function applyTextTransformations(text, customTransformations) {
1331
+ function applyTextTransformations(text, customTransformations, options = {}) {
1244
1332
  if (!text || !customTransformations?.replacements) return text
1245
1333
  let result = text
1246
1334
  for (const rule of customTransformations.replacements) {
1335
+ // Code blocks are verbatim: only rules explicitly marked applyToCode
1336
+ // (like the rpai -> rpk ai binary-name rewrite) may touch them.
1337
+ if (options.code && !rule.applyToCode) continue
1247
1338
  try {
1248
1339
  const flags = rule.flags || 'g'
1249
1340
  result = result.replace(new RegExp(rule.pattern, flags), rule.replacement)
@@ -1254,6 +1345,24 @@ function applyTextTransformations(text, customTransformations) {
1254
1345
  return result
1255
1346
  }
1256
1347
 
1348
+ /**
1349
+ * Apply text transformations to examples content line by line. Indented
1350
+ * lines are verbatim commands: only rules flagged applyToCode may touch
1351
+ * them (a caption rule once rewrote '{"quotas":...}' inside a command to
1352
+ * '{`quotas`:...}'). Caption lines get the full rule set.
1353
+ * @param {string} text
1354
+ * @param {Object|null} customTransformations
1355
+ * @returns {string}
1356
+ */
1357
+ function applyTextTransformationsToExamples(text, customTransformations) {
1358
+ if (!text || !customTransformations?.replacements) return text
1359
+ return text.split('\n').map(line =>
1360
+ /^[ ]{2,}\S/.test(line)
1361
+ ? applyTextTransformations(line, customTransformations, { code: true })
1362
+ : applyTextTransformations(line, customTransformations)
1363
+ ).join('\n')
1364
+ }
1365
+
1257
1366
  /**
1258
1367
  * Format description by adding backticks around flags and code
1259
1368
  * @param {string} desc - Description text
@@ -1470,9 +1579,19 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1470
1579
  // Also handles '--flag/-f', '--flag help', and similar patterns
1471
1580
  .replace(/'(--[a-z][-a-z0-9]*(?:\/-[a-z])?(?:\s+\w+)?)'/gi, '$1')
1472
1581
  .replace(/'(-[a-z])'/gi, '$1')
1582
+ // Drop a dangling example lead-in with nothing after it (upstream help
1583
+ // strings sometimes end mid-example, which would render as "for example,.")
1584
+ .replace(/[,;]?\s*\be\.g\.[\s,]*$/i, '')
1585
+ // Stray space before a closing parenthesis (upstream help typo)
1586
+ .replace(/ +\)/g, ')')
1473
1587
  // === STYLE GUIDE COMPLIANCE ===
1474
- .replace(/\be\.g\.\s*/gi, 'for example, ')
1475
- .replace(/\bi\.e\.\s*/gi, 'that is, ')
1588
+ // "e.g.:" introduces a block; keep the colon instead of rendering ", :".
1589
+ // Horizontal whitespace only: crossing a newline would glue the next
1590
+ // line (often a code-block placeholder) onto this sentence.
1591
+ .replace(/,?[^\S\n]*\be\.g\.:[^\S\n]*/gi, ', for example: ')
1592
+ .replace(/, for example: $/gm, ', for example:')
1593
+ .replace(/\be\.g\.[^\S\n]*/gi, 'for example, ')
1594
+ .replace(/\bi\.e\.[^\S\n]*/gi, 'that is, ')
1476
1595
  // === TYPO CORRECTIONS ===
1477
1596
  // Fix "an" before consonant sounds (common source typos)
1478
1597
  .replace(/\ban\s+(prod|dev|test|local|remote|new|cluster|config|file)\b/gi, 'a $1')
@@ -1592,12 +1711,23 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1592
1711
  // e.g., "(see #2904)" → "(see https://github.com/redpanda-data/redpanda/issues/2904[#2904])"
1593
1712
  result = result.replace(/(?<![`\w])#(\d{4,})(?![`\w])/g, 'https://github.com/redpanda-data/redpanda/issues/$1[#$1]')
1594
1713
 
1714
+ // Escape template placeholders like {name} in prose: Asciidoctor would
1715
+ // consume them as attribute references and drop them from the output.
1716
+ // Backtick spans can hold raw code at this stage, so only prose segments
1717
+ // between spans are touched. {vbar} stays: it is a real attribute used to
1718
+ // escape pipes in table cells.
1719
+ result = result.split(/(`[^`]*`)/).map((segment, idx) =>
1720
+ idx % 2 === 1 ? segment : segment.replace(/(?<![\\`\w{]){([a-z][\w-]{0,30})}/g, (match, token) =>
1721
+ token === 'vbar' ? match : `\\{${token}}`)
1722
+ ).join('')
1723
+
1595
1724
  // Restore early code blocks FIRST (from indented command/YAML detection)
1596
1725
  // This must happen before inline code restoration so that placeholders
1597
1726
  // inside the early code blocks (like __INLINE_CODE_X__) get resolved
1598
1727
  // Use function replacements to prevent $ special-pattern interpretation (e.g. `$` → before-match)
1599
1728
  earlyCodeBlocks.forEach((block, i) => {
1600
- result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => block)
1729
+ const transformed = applyTextTransformations(block, customTransformations, { code: true })
1730
+ result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => transformed)
1601
1731
  })
1602
1732
 
1603
1733
  // Restore inline code
@@ -1807,11 +1937,37 @@ function parseDescriptionSections(desc) {
1807
1937
  let currentContent = []
1808
1938
  const mainLines = []
1809
1939
 
1810
- for (const line of lines) {
1940
+ let skipNext = false
1941
+ for (let i = 0; i < lines.length; i++) {
1942
+ const line = lines[i]
1943
+ if (skipNext) {
1944
+ skipNext = false
1945
+ continue
1946
+ }
1811
1947
  // Check for ALL CAPS header (at least 2 chars, possibly with spaces, hyphens, slashes, or ampersands)
1812
1948
  // Examples: "FIELDS", "BALANCER STATUS", "PRODUCER ID & EPOCH"
1813
- const headerMatch = line.match(/^([A-Z][A-Z\s\-\/&]{0,}[A-Z])$/)
1949
+ // A line with runs of multiple spaces is a column-header row of aligned
1950
+ // sample output (for example "PARTITION REASON"), not a section
1951
+ // header, so leave it in the content.
1952
+ let headerMatch = / /.test(line) ? null : line.match(/^([A-Z][A-Z\s\-\/&]{0,}[A-Z])$/)
1953
+ // An all-caps line directly after a "$ command" invocation is the title
1954
+ // of that command's sample output (for example "DECOMMISSION PROGRESS"),
1955
+ // not a section header. Splitting there would strand the invocation in
1956
+ // one section and its output table in another.
1957
+ if (headerMatch) {
1958
+ for (let p = i - 1; p >= 0; p--) {
1959
+ if (lines[p].trim() === '') break
1960
+ if (/^\$\s/.test(lines[p])) { headerMatch = null; break }
1961
+ }
1962
+ }
1814
1963
  if (headerMatch) {
1964
+ // Help text often underlines section titles with a run of = or -
1965
+ // characters. Consume the underline: left in the content, a line of
1966
+ // 4+ = or - is an AsciiDoc block delimiter and breaks the page
1967
+ // (unterminated example block).
1968
+ if (i + 1 < lines.length && /^\s*(={3,}|-{3,})\s*$/.test(lines[i + 1])) {
1969
+ skipNext = true
1970
+ }
1815
1971
  // Save previous section
1816
1972
  if (currentSection) {
1817
1973
  // Preserve indentation: only remove leading/trailing blank lines, not spaces
@@ -1832,6 +1988,13 @@ function parseDescriptionSections(desc) {
1832
1988
  sections[currentSection] = trimBlankLines(currentContent.join('\n'))
1833
1989
  }
1834
1990
 
1991
+ const delimiterRun = /^\s*(={4,}|-{4,})\s*$/
1992
+ for (const [name, body] of Object.entries(sections)) {
1993
+ if (body.split('\n').some(l => delimiterRun.test(l))) {
1994
+ console.warn(`Warning: section "${name}" contains a bare =/- delimiter run; it may break AsciiDoc rendering`)
1995
+ }
1996
+ }
1997
+
1835
1998
  return {
1836
1999
  mainDescription: mainLines.join('\n').trim(),
1837
2000
  sections
@@ -1941,6 +2104,45 @@ function dashify(commandPath) {
1941
2104
  * @param {string} desc - Full description
1942
2105
  * @returns {string} Short description
1943
2106
  */
2107
+ /**
2108
+ * Collapse runs of three or more newlines to a single blank line, except
2109
+ * inside AsciiDoc delimited blocks (----, ====, ...., |===, and -- open
2110
+ * blocks), where blank lines are content.
2111
+ * @param {string} text - Rendered page content
2112
+ * @returns {string}
2113
+ */
2114
+ function collapseBlankLines(text) {
2115
+ const lines = text.split('\n')
2116
+ const out = []
2117
+ let inBlock = false
2118
+ let blockDelim = null
2119
+ let blankRun = 0
2120
+
2121
+ for (const line of lines) {
2122
+ const trimmed = line.trimEnd()
2123
+ const isDelim = /^(-{4,}|={4,}|\.{4,}|\|===|--)$/.test(trimmed)
2124
+ if (isDelim) {
2125
+ if (!inBlock) {
2126
+ inBlock = true
2127
+ blockDelim = trimmed
2128
+ } else if (trimmed === blockDelim || (blockDelim === '|===' && trimmed === '|===')) {
2129
+ inBlock = false
2130
+ blockDelim = null
2131
+ }
2132
+ }
2133
+
2134
+ if (!inBlock && trimmed === '') {
2135
+ blankRun++
2136
+ if (blankRun > 1) continue
2137
+ } else {
2138
+ blankRun = 0
2139
+ }
2140
+ out.push(line)
2141
+ }
2142
+
2143
+ return out.join('\n')
2144
+ }
2145
+
1944
2146
  function capToTwoSentences(desc) {
1945
2147
  if (!desc) return ''
1946
2148
  if (typeof desc !== 'string') return String(desc)
@@ -1949,6 +2151,19 @@ function capToTwoSentences(desc) {
1949
2151
  // Pattern: matches == Section Header and everything after it
1950
2152
  let cleaned = desc.replace(/\s*==\s+.+$/s, '')
1951
2153
 
2154
+ // Delimited code/output blocks never belong in a summary. Indented help
2155
+ // examples are converted to [,text]/[,bash]/[.no-copy] blocks before this
2156
+ // runs. A block introduced by a colon ends the summary there (the prose
2157
+ // after it would read as a non sequitur without the sample); otherwise
2158
+ // drop the block and keep the surrounding prose.
2159
+ const blockRe = /\n?\[[^\]\n]*\]\n----\n[\s\S]*?\n----|\n----\n[\s\S]*?\n----/
2160
+ const firstBlock = cleaned.match(blockRe)
2161
+ if (firstBlock && /:\s*$/.test(cleaned.slice(0, firstBlock.index))) {
2162
+ cleaned = cleaned.slice(0, firstBlock.index)
2163
+ } else {
2164
+ cleaned = cleaned.replace(new RegExp(blockRe.source, 'g'), '')
2165
+ }
2166
+
1952
2167
  // Remove indented content (tables, lists, etc.) after colons before sentence detection
1953
2168
  // Pattern: text ending with colon, optionally followed by blank line, then indented content
1954
2169
  // Examples:
@@ -1982,11 +2197,26 @@ function capToTwoSentences(desc) {
1982
2197
  })
1983
2198
  }
1984
2199
 
2200
+ // Protect decimal points in version-like numbers (OAuth 2.0, HTTP 1.1) so
2201
+ // they neither split a sentence nor cause the leading fragment to be
2202
+ // dropped by the sentence matcher
2203
+ normalized = normalized.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')
2204
+
1985
2205
  // Match sentences
1986
- const sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
2206
+ let sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
2207
+
2208
+ // Never drop a leading fragment: if the first matched sentence does not
2209
+ // start at the beginning of the text (an unterminated prefix was skipped),
2210
+ // glue the prefix back onto it
2211
+ if (sentences && sentences.length > 0) {
2212
+ const firstIdx = normalized.indexOf(sentences[0])
2213
+ if (firstIdx > 0) {
2214
+ sentences = [normalized.slice(0, firstIdx) + sentences[0], ...sentences.slice(1)]
2215
+ }
2216
+ }
1987
2217
  if (!sentences || sentences.length === 0) {
1988
2218
  // Restore and return
1989
- let result = normalized
2219
+ let result = normalized.replace(/__DECIMAL__/g, '.')
1990
2220
  placeholders.forEach(({ ph, original }) => {
1991
2221
  result = result.replace(ph, original)
1992
2222
  })
@@ -1998,6 +2228,9 @@ function capToTwoSentences(desc) {
1998
2228
 
1999
2229
  let result = sentences.slice(0, 2).join('')
2000
2230
 
2231
+ // Restore decimal points
2232
+ result = result.replace(/__DECIMAL__/g, '.')
2233
+
2001
2234
  // Restore abbreviations
2002
2235
  placeholders.forEach(({ ph, original }) => {
2003
2236
  result = result.replace(ph, original)
@@ -2087,6 +2320,61 @@ function findTopLevelWithSubcommands(tree) {
2087
2320
  return result
2088
2321
  }
2089
2322
 
2323
+ /**
2324
+ * Known rpk plugins that are managed separately from rpk core (they have
2325
+ * install/uninstall/upgrade shim commands, and their real command tree only
2326
+ * appears when the plugin binary is installed in the generation environment).
2327
+ * Shared with rpk-docs-handler.js, which iterates this list to install each
2328
+ * plugin before running --print-tree.
2329
+ */
2330
+ const KNOWN_PLUGINS = ['ai', 'check', 'connect', 'k8s', 'oxla']
2331
+
2332
+ /**
2333
+ * Subcommands that rpk core ships as a built-in shim for managed plugins,
2334
+ * present even when the plugin binary itself is not installed.
2335
+ */
2336
+ const PLUGIN_SHIM_COMMANDS = new Set(['install', 'uninstall', 'upgrade'])
2337
+
2338
+ /**
2339
+ * Check whether a command path belongs to a protected plugin's subtree
2340
+ * (including the plugin's own top-level command).
2341
+ * @param {string} commandPath - Full command path, e.g. 'rpk k8s multicluster'
2342
+ * @param {Array<string>} protectedPlugins - Plugin names, e.g. ['k8s']
2343
+ * @returns {boolean}
2344
+ */
2345
+ function isProtectedCommandPath(commandPath, protectedPlugins) {
2346
+ return protectedPlugins.some(pl => commandPath === `rpk ${pl}` || commandPath.startsWith(`rpk ${pl} `))
2347
+ }
2348
+
2349
+ /**
2350
+ * Determine which managed plugins must be protected for this run.
2351
+ *
2352
+ * Auto-detects plugins whose commands are missing from this run's tree:
2353
+ * rpk core ships only a shim (install/uninstall/upgrade) for managed
2354
+ * plugins; the real commands appear only when the plugin binary is
2355
+ * installed in the generation environment. If a known plugin's subtree is
2356
+ * absent or shim-only, its existing pages reflect the plugin, not stale
2357
+ * commands, so they must be preserved. Pre-GA windows hit this every time:
2358
+ * the plugin publisher only promotes stable X.Y.Z releases, so `rpk <plugin>
2359
+ * install` finds nothing until GA (see redpanda-data/docs#1831).
2360
+ *
2361
+ * @param {Array} commands - Flat command list from flattenCommands()
2362
+ * @param {Array<string>} explicitProtected - Plugins the caller already knows
2363
+ * failed to install this run (merged with auto-detection)
2364
+ * @returns {Array<string>} Deduplicated protected plugin names
2365
+ */
2366
+ function detectProtectedPlugins(commands, explicitProtected = []) {
2367
+ const autoProtected = KNOWN_PLUGINS.filter(pl => {
2368
+ const subNames = commands
2369
+ .filter(c => c.path.startsWith(`rpk ${pl} `))
2370
+ .map(c => c.path.split(' ')[2])
2371
+ const hasTopLevel = commands.some(c => c.path === `rpk ${pl}`)
2372
+ if (!hasTopLevel) return true
2373
+ return subNames.length === 0 || subNames.every(n => PLUGIN_SHIM_COMMANDS.has(n))
2374
+ })
2375
+ return [...new Set([...explicitProtected, ...autoProtected])]
2376
+ }
2377
+
2090
2378
  /**
2091
2379
  * Static entries that always appear at the top of the rpk nav section,
2092
2380
  * before the auto-generated command entries. These are hand-written pages
@@ -2095,6 +2383,10 @@ function findTopLevelWithSubcommands(tree) {
2095
2383
  * landing page (the section header), so it is skipped during generation.
2096
2384
  */
2097
2385
  const STATIC_RPK_NAV_ENTRIES = [
2386
+ // The root rpk command page is generated but skipped by the entry loop
2387
+ // (its path has no group segment), so it is listed here to keep it in
2388
+ // the nav ahead of the hand-written pages.
2389
+ '*** xref:reference:rpk/rpk.adoc[]',
2098
2390
  '*** xref:reference:rpk/rpk-commands.adoc[]',
2099
2391
  '*** xref:reference:rpk/rpk-x-options.adoc[rpk -X]',
2100
2392
  ]
@@ -2109,7 +2401,7 @@ const STATIC_RPK_NAV_ENTRIES = [
2109
2401
  * @param {Set} topLevelWithSubcommands - Set of top-level command names with subcommands
2110
2402
  * @returns {Object} { navUpdated, navEntriesGenerated }
2111
2403
  */
2112
- function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands) {
2404
+ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands, protectedPlugins = []) {
2113
2405
  if (!fs.existsSync(navFile)) {
2114
2406
  console.warn(`Warning: nav file not found, skipping nav update: ${navFile}`)
2115
2407
  return { navUpdated: false }
@@ -2140,10 +2432,13 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2140
2432
  const newEntries = []
2141
2433
  for (const { path: commandPath } of commands) {
2142
2434
  if (commandPath === 'rpk') continue
2435
+ // Protected plugins get no generated entries: this run's tree has at
2436
+ // most the shim (install/uninstall/upgrade) for them, so their previous
2437
+ // nav block is preserved in place instead (below).
2438
+ if (isProtectedCommandPath(commandPath, protectedPlugins)) continue
2143
2439
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) continue
2144
2440
  if (shouldUsePartialDir(resolvedOverrides, commandPath)) continue
2145
- if (commandPath.startsWith('rpk cloud')) continue
2146
- if (commandPath.startsWith('rpk security secret')) continue
2441
+ if (isCloudSecretCommand(commandPath)) continue
2147
2442
 
2148
2443
  const parts = commandPath.split(' ')
2149
2444
  const stars = '*'.repeat(parts.length + 1)
@@ -2155,12 +2450,65 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2155
2450
  newEntries.push(`${stars} xref:${xrefPath}[]`)
2156
2451
  }
2157
2452
 
2158
- // Rebuild: header + static entries + generated entries
2159
- const newSection = [
2160
- lines[sectionStart],
2161
- ...STATIC_RPK_NAV_ENTRIES,
2162
- ...newEntries,
2163
- ]
2453
+ // Preserve existing entries for protected plugins. Their commands are
2454
+ // absent (or shim-only) in this run's tree, so nothing was generated for
2455
+ // them above. Each plugin's previous nav block (parent entry plus nested
2456
+ // children) is kept together and spliced back in at its original position,
2457
+ // anchored to the nearest preceding entry that survives regeneration —
2458
+ // never appended at the end, where the children would render under the
2459
+ // wrong parent.
2460
+ const stripStars = (line) => line.replace(/^\*+ /, '')
2461
+ const emittedTargets = new Set([...STATIC_RPK_NAV_ENTRIES, ...newEntries].map(stripStars))
2462
+ // Map of anchor target (or null for "before all surviving entries") to the
2463
+ // preserved lines that follow that anchor, in original order.
2464
+ const preservedBlocks = new Map()
2465
+ let preservedCount = 0
2466
+ const preservedPlugins = new Set()
2467
+ if (protectedPlugins.length > 0) {
2468
+ const patternsFor = (pl) => [`reference:rpk/rpk-${pl}/`, `reference:rpk/rpk-${pl}.adoc`]
2469
+ const pluginFor = (line) => protectedPlugins.find(pl => patternsFor(pl).some(pat => line.includes(pat)))
2470
+ let lastAnchor = null
2471
+ for (const line of lines.slice(sectionStart + 1, sectionEnd)) {
2472
+ const plugin = pluginFor(line)
2473
+ if (plugin) {
2474
+ // Never duplicate an entry that regeneration already emits.
2475
+ if (emittedTargets.has(stripStars(line))) continue
2476
+ if (!preservedBlocks.has(lastAnchor)) preservedBlocks.set(lastAnchor, [])
2477
+ preservedBlocks.get(lastAnchor).push(line)
2478
+ preservedCount++
2479
+ preservedPlugins.add(plugin)
2480
+ } else if (emittedTargets.has(stripStars(line))) {
2481
+ lastAnchor = stripStars(line)
2482
+ }
2483
+ }
2484
+ if (preservedCount > 0) {
2485
+ console.log(` Preserving ${preservedCount} nav entries for plugins: ${[...preservedPlugins].join(', ')}`)
2486
+ }
2487
+ }
2488
+
2489
+ // Rebuild: header + static entries + generated entries, splicing each
2490
+ // preserved block back in directly after its anchor entry.
2491
+ const newSection = [lines[sectionStart]]
2492
+ const pushEntry = (line) => {
2493
+ newSection.push(line)
2494
+ const target = stripStars(line)
2495
+ if (preservedBlocks.has(target)) {
2496
+ newSection.push(...preservedBlocks.get(target))
2497
+ preservedBlocks.delete(target)
2498
+ }
2499
+ }
2500
+ // Blocks that preceded every surviving entry in the old nav go right after
2501
+ // the section header, before the static entries.
2502
+ if (preservedBlocks.has(null)) {
2503
+ newSection.push(...preservedBlocks.get(null))
2504
+ preservedBlocks.delete(null)
2505
+ }
2506
+ for (const line of STATIC_RPK_NAV_ENTRIES) pushEntry(line)
2507
+ for (const line of newEntries) pushEntry(line)
2508
+ // Safety net: if an anchor vanished between collection and rebuild (it
2509
+ // cannot with the logic above, but never silently drop preserved entries),
2510
+ // append any remaining blocks at the end.
2511
+ for (const block of preservedBlocks.values()) newSection.push(...block)
2164
2512
 
2165
2513
  const newLines = [
2166
2514
  ...lines.slice(0, sectionStart),
@@ -2187,7 +2535,12 @@ async function generateRpkDocs(options = {}) {
2187
2535
  pluginVersions = {},
2188
2536
  draftMissing = false,
2189
2537
  flatOutput = false, // If true, output all files flat (legacy behavior)
2190
- navFile // Optional: path to nav.adoc for automatic nav updates
2538
+ navFile, // Optional: path to nav.adoc for automatic nav updates
2539
+ // Plugins that exist but could not be installed for this run (for example,
2540
+ // rpk k8s before its GA plugin publishes). Their pages and nav entries are
2541
+ // preserved instead of treated as stale, because their absence from the
2542
+ // command tree reflects the generation environment, not the product.
2543
+ protectedPlugins = []
2191
2544
  } = options
2192
2545
 
2193
2546
  // Register partials
@@ -2225,6 +2578,16 @@ async function generateRpkDocs(options = {}) {
2225
2578
  // Flatten command tree
2226
2579
  const commands = flattenCommands(tree)
2227
2580
 
2581
+ // Determine which managed plugins are protected this run: explicitly
2582
+ // passed by the caller (failed installs) merged with auto-detection of
2583
+ // absent or shim-only plugin subtrees. Protection covers the whole
2584
+ // pipeline — page writes, the stale-file sweep, and nav updates — so a
2585
+ // run without the plugin binary leaves the plugin's docs fully untouched.
2586
+ const effectiveProtectedPlugins = detectProtectedPlugins(commands, protectedPlugins)
2587
+ if (effectiveProtectedPlugins.length > 0) {
2588
+ console.log(` Protected plugins this run (pages and nav preserved, not regenerated): ${effectiveProtectedPlugins.join(', ')}`)
2589
+ }
2590
+
2228
2591
  // Find top-level commands with subcommands (for directory structure)
2229
2592
  const topLevelWithSubcommands = findTopLevelWithSubcommands(tree)
2230
2593
 
@@ -2246,6 +2609,18 @@ async function generateRpkDocs(options = {}) {
2246
2609
  continue
2247
2610
  }
2248
2611
 
2612
+ // Protected plugins: skip generating the whole subtree, parent page
2613
+ // included. This run's tree has at most the shim
2614
+ // (install/uninstall/upgrade) for these plugins, so regenerating would
2615
+ // overwrite full-plugin pages from an earlier successful run — for
2616
+ // example, rewriting rpk-k8s.adoc with a Subcommands table reduced to
2617
+ // the shim and orphaning the preserved child pages. Existing pages stay
2618
+ // untouched, exactly as in the fully-absent-plugin case.
2619
+ if (isProtectedCommandPath(commandPath, effectiveProtectedPlugins)) {
2620
+ filesSkipped++
2621
+ continue
2622
+ }
2623
+
2249
2624
  // Check if command should be excluded
2250
2625
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) {
2251
2626
  filesSkipped++
@@ -2273,8 +2648,9 @@ async function generateRpkDocs(options = {}) {
2273
2648
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2274
2649
  examplesContent = filterExamples(sectionContent, mergedCommand.excludeExamples)
2275
2650
  }
2276
- // Apply text transformations (e.g. rpai → rpk ai) then format
2277
- examplesContent = applyTextTransformations(examplesContent, textTransformations)
2651
+ // Apply text transformations (e.g. rpai → rpk ai) then format.
2652
+ // Command lines only get code-safe rules.
2653
+ examplesContent = applyTextTransformationsToExamples(examplesContent, textTransformations)
2278
2654
  sections[sectionName] = formatExamples(examplesContent)
2279
2655
  } else {
2280
2656
  sections[sectionName] = formatDescription(sectionContent, textTransformations)
@@ -2291,8 +2667,9 @@ async function generateRpkDocs(options = {}) {
2291
2667
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2292
2668
  examplesContent = filterExamples(examplesContent, mergedCommand.excludeExamples)
2293
2669
  }
2294
- // Apply text transformations (e.g. rpai → rpk ai) then format
2295
- examplesContent = applyTextTransformations(examplesContent, textTransformations)
2670
+ // Apply text transformations (e.g. rpai → rpk ai) then format.
2671
+ // Command lines only get code-safe rules.
2672
+ examplesContent = applyTextTransformationsToExamples(examplesContent, textTransformations)
2296
2673
  sections.EXAMPLES = formatExamples(examplesContent)
2297
2674
  }
2298
2675
 
@@ -2365,6 +2742,10 @@ async function generateRpkDocs(options = {}) {
2365
2742
  // Build subcommands with correct xref paths
2366
2743
  // Filter out excluded and asPartial subcommands — excluded have no file,
2367
2744
  // asPartial ones live in the partials directory with no linkable xref.
2745
+ // rpk cloud and rpk security secret rows stay in the table: their pages
2746
+ // are routed to the cloud partials directory and published by
2747
+ // cloud-docs, so the xref below links across to the cloud component
2748
+ // instead of dropping the row (which hid the subcommand entirely).
2368
2749
  const subcommands = (command.commands || [])
2369
2750
  .filter(sub => {
2370
2751
  const subPath = `${commandPath} ${sub.name}`
@@ -2389,6 +2770,13 @@ async function generateRpkDocs(options = {}) {
2389
2770
  }
2390
2771
  }
2391
2772
 
2773
+ // rpk cloud and rpk security secret pages are routed to the cloud
2774
+ // partials directory and published by cloud-docs, so rows in regular
2775
+ // pages must link across to the cloud component.
2776
+ if (cloudSecretDir && isCloudSecretCommand(subPath) && !isCloudSecretCommand(commandPath)) {
2777
+ xrefPath = `${CLOUD_DOCS_COMPONENT}:${xrefPath}`
2778
+ }
2779
+
2392
2780
  // Use overridden description if available
2393
2781
  const subOverride = resolvedOverrides?.commands?.[subPath]
2394
2782
  const subDescription = subOverride?.description || sub.description || ''
@@ -2398,6 +2786,8 @@ async function generateRpkDocs(options = {}) {
2398
2786
  fullPath: subPath,
2399
2787
  dashifiedPath: dashifiedSubPath,
2400
2788
  xrefPath,
2789
+ cloudOnly: subOverride?.cloudOnly === true,
2790
+ selfHostedOnly: subOverride?.selfHostedOnly === true,
2401
2791
  shortDesc: ensurePeriod(capToTwoSentences(formatDescription(subDescription, textTransformations, { skipTableConversion: true, skipListConversion: true })))
2402
2792
  }
2403
2793
  })
@@ -2431,7 +2821,24 @@ async function generateRpkDocs(options = {}) {
2431
2821
  return `${parentPath} ${alias}`
2432
2822
  }),
2433
2823
  aliasNotes: commandOverride.aliasNotes,
2434
- flags: (mergedCommand.flags || []).map(flag => ({
2824
+ // A curated override section titled "Flags" wins over the extracted
2825
+ // flag table: rendering both produced duplicate == Flags headings and
2826
+ // conflicting content (rpk connect run). Curation is authoritative;
2827
+ // the warning tells maintainers the override can be dropped to adopt
2828
+ // the extracted table.
2829
+ flags: (() => {
2830
+ const hasCuratedFlagsSection = Object.values(processedContent.sections || {})
2831
+ .some(items => (items || []).some(item => /^flags$/i.test(item.title || '')))
2832
+ if (hasCuratedFlagsSection && (mergedCommand.flags || []).length > 0) {
2833
+ console.warn(
2834
+ `Warning: ${commandPath} has a curated "Flags" override section; ` +
2835
+ `skipping the extracted flag table (${mergedCommand.flags.length} flags). ` +
2836
+ 'Remove the override section to adopt the extracted table.'
2837
+ )
2838
+ return []
2839
+ }
2840
+ return mergedCommand.flags || []
2841
+ })().map(flag => ({
2435
2842
  ...flag,
2436
2843
  name: flag.shorthand ? `-${flag.shorthand}, --${flag.name}` : `--${flag.name}`,
2437
2844
  type: flag.type || '-',
@@ -2542,15 +2949,31 @@ async function generateRpkDocs(options = {}) {
2542
2949
  hasUnknownSections: unknownSections.length > 0
2543
2950
  }
2544
2951
 
2545
- // Render template
2546
- const content = template(context)
2952
+ // Render template, then collapse runs of blank lines left behind by
2953
+ // absent optional sections (plugin commands have no aliases or flags, so
2954
+ // the separators between skipped blocks stack up). Blank lines inside
2955
+ // delimited blocks are preserved: they are content there.
2956
+ const content = collapseBlankLines(template(context))
2957
+
2958
+ // Duplicate top-level headings almost always mean an override adds a
2959
+ // section the page already renders (or embeds its own heading in raw
2960
+ // content). The page still builds, but anchors collide and readers see
2961
+ // the same section twice.
2962
+ const h2Counts = new Map()
2963
+ for (const line of content.split('\n')) {
2964
+ const h2 = line.match(/^== (\S.*)$/)
2965
+ if (h2) h2Counts.set(h2[1], (h2Counts.get(h2[1]) || 0) + 1)
2966
+ }
2967
+ for (const [heading, count] of h2Counts) {
2968
+ if (count > 1) {
2969
+ console.warn(`⚠️ ${commandPath}: heading "== ${heading}" appears ${count} times. Check the override content for this command.`)
2970
+ }
2971
+ }
2547
2972
 
2548
2973
  // Determine output path
2549
2974
  // Check if this command should go to cloudSecretDir
2550
- const isCloudCommand = commandPath.startsWith('rpk cloud')
2551
- const isSecuritySecretCommand = commandPath.startsWith('rpk security secret')
2552
2975
  const useCloudSecretDir = cloudSecretDir && (
2553
- isCloudCommand || isSecuritySecretCommand || shouldUsePartialDir(resolvedOverrides, commandPath)
2976
+ isCloudSecretCommand(commandPath) || shouldUsePartialDir(resolvedOverrides, commandPath)
2554
2977
  )
2555
2978
  const effectiveOutputDir = useCloudSecretDir ? cloudSecretDir : outputDir
2556
2979
 
@@ -2614,9 +3037,19 @@ async function generateRpkDocs(options = {}) {
2614
3037
  .map(entry => path.join(baseDir, entry.name))
2615
3038
  }
2616
3039
 
3040
+ // Protected plugins (computed before the write loop, see
3041
+ // detectProtectedPlugins): their existing pages reflect the plugin, not
3042
+ // stale commands, so their directories are excluded from the sweep.
3043
+ const protectedDirNames = new Set(effectiveProtectedPlugins.map(pl => `rpk-${pl}`))
2617
3044
  const scanDirs = new Set()
2618
3045
  for (const dir of rpkSubdirs(outputDir)) scanDirs.add(dir)
2619
3046
  for (const dir of rpkSubdirs(cloudSecretDir)) scanDirs.add(dir)
3047
+ for (const dir of [...scanDirs]) {
3048
+ if (protectedDirNames.has(path.basename(dir))) {
3049
+ console.log(` Preserving ${path.basename(dir)}/ (plugin commands absent from this run's tree)`)
3050
+ scanDirs.delete(dir)
3051
+ }
3052
+ }
2620
3053
 
2621
3054
  for (const dir of scanDirs) {
2622
3055
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -2644,7 +3077,7 @@ async function generateRpkDocs(options = {}) {
2644
3077
  // Update nav.adoc if a path was provided
2645
3078
  let navResult = { navUpdated: false }
2646
3079
  if (navFile) {
2647
- navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands)
3080
+ navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands, effectiveProtectedPlugins)
2648
3081
  }
2649
3082
 
2650
3083
  return {
@@ -2681,9 +3114,12 @@ module.exports = {
2681
3114
  shouldExcludeCommand,
2682
3115
  shouldUsePartialDir,
2683
3116
  updateNavFile,
3117
+ KNOWN_PLUGINS,
3118
+ detectProtectedPlugins,
2684
3119
  getCommandMetadata,
2685
3120
  processContentArray,
2686
3121
  // Exported for testing
2687
3122
  filterExamples,
2688
- formatExamples
3123
+ formatExamples,
3124
+ applyTextTransformationsToExamples
2689
3125
  }