@redpanda-data/docs-extensions-and-macros 5.2.4 → 5.3.0

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.
@@ -778,6 +778,76 @@ function convertIndentedCodeBlocksToAsciiDoc(text, codeBlockStore = []) {
778
778
  while (i < lines.length) {
779
779
  const line = lines[i]
780
780
 
781
+ // Unindented shell example: "$ rpk ..." at column 0 followed by its
782
+ // sample output on the contiguous lines below. Rendered as a command
783
+ // block plus a no-copy output block so the output's =-underlined titles
784
+ // and aligned columns are never parsed as prose or headings.
785
+ if (/^\$\s/.test(line)) {
786
+ const command = line.replace(/^\$\s+/, '')
787
+ let j = i + 1
788
+ const outputLines = []
789
+ while (j < lines.length && lines[j].trim() !== '' && !/^\$\s/.test(lines[j])) {
790
+ outputLines.push(lines[j])
791
+ j++
792
+ }
793
+ let block = `\n[,bash]\n----\n${command}\n----\n`
794
+ if (outputLines.length > 0) {
795
+ block += `\n[.no-copy]\n----\n${outputLines.join('\n')}\n----\n`
796
+ }
797
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
798
+ codeBlockStore.push(block)
799
+ result.push('')
800
+ result.push(placeholder)
801
+ result.push('')
802
+ i = j
803
+ continue
804
+ }
805
+
806
+ // Colon-introduced code sample: prose ending with ":" followed by an
807
+ // indented block that is not a list (Cedar policies, config snippets).
808
+ // Deeply indented (4+ space) blocks are literals by help-text convention
809
+ // even without a colon introducer (path templates like
810
+ // " kafka/{topic}/{partition}_{revision}/" would otherwise render as
811
+ // prose whose braces Asciidoctor eats as attribute references).
812
+ // Captured verbatim (dedented) so inline-code transforms never touch it.
813
+ const prevNonBlank = [...result].reverse().find(l => l.trim() !== '')
814
+ // Only a chunk that starts after a blank line is a standalone literal;
815
+ // a deeply indented line mid-chunk is a wrapped continuation of a table
816
+ // row or list item and belongs to the converters below.
817
+ const atChunkStart = result.length === 0 || result[result.length - 1].trim() === ''
818
+ if (
819
+ (
820
+ (prevNonBlank && /:\s*$/.test(prevNonBlank) && /^[ ]{2,}\S/.test(line)) ||
821
+ (atChunkStart && /^[ ]{4,}\S/.test(line))
822
+ ) &&
823
+ !/^[ ]{2,}(-|\*|\d+[.)])\s/.test(line) &&
824
+ !/^[ ]{2,}(--|rpk\s|\$\s)/.test(line)
825
+ ) {
826
+ const blockLines = []
827
+ let j = i
828
+ while (j < lines.length && (/^[ ]{2,}\S/.test(lines[j]) || lines[j].trim() === '')) {
829
+ if (lines[j].trim() === '' && (j + 1 >= lines.length || !/^[ ]{2,}\S/.test(lines[j + 1]))) break
830
+ blockLines.push(lines[j])
831
+ j++
832
+ }
833
+ // Column-aligned layouts (two or more lines with a run of spaces
834
+ // separating columns) are definition tables, not code: leave them for
835
+ // the indented-table/YAML converters below.
836
+ const alignedLines = blockLines.filter(l => /\S\s{2,}\S/.test(l.trim())).length
837
+ if (alignedLines < 2) {
838
+ const indent = Math.min(...blockLines.filter(l => l.trim() !== '').map(l => l.search(/\S/)))
839
+ const dedented = blockLines.map(l => l.slice(indent)).join('\n')
840
+ const codeBlock = `\n[,text]\n----\n${dedented}\n----\n`
841
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
842
+ codeBlockStore.push(codeBlock)
843
+ result.push('')
844
+ result.push(placeholder)
845
+ result.push('')
846
+ i = j
847
+ continue
848
+ }
849
+ }
850
+
781
851
  // Check for indented command example: 2+ spaces then --, rpk, or $ (shell prompt)
782
852
  // e.g. " --job-name test --labels ..."
783
853
  // e.g. " rpk cluster info"
@@ -1240,10 +1310,13 @@ function convertIndentedTablesToAsciiDoc(text, options = {}) {
1240
1310
  * @param {Object|null} customTransformations
1241
1311
  * @returns {string}
1242
1312
  */
1243
- function applyTextTransformations(text, customTransformations) {
1313
+ function applyTextTransformations(text, customTransformations, options = {}) {
1244
1314
  if (!text || !customTransformations?.replacements) return text
1245
1315
  let result = text
1246
1316
  for (const rule of customTransformations.replacements) {
1317
+ // Code blocks are verbatim: only rules explicitly marked applyToCode
1318
+ // (like the rpai -> rpk ai binary-name rewrite) may touch them.
1319
+ if (options.code && !rule.applyToCode) continue
1247
1320
  try {
1248
1321
  const flags = rule.flags || 'g'
1249
1322
  result = result.replace(new RegExp(rule.pattern, flags), rule.replacement)
@@ -1254,6 +1327,24 @@ function applyTextTransformations(text, customTransformations) {
1254
1327
  return result
1255
1328
  }
1256
1329
 
1330
+ /**
1331
+ * Apply text transformations to examples content line by line. Indented
1332
+ * lines are verbatim commands: only rules flagged applyToCode may touch
1333
+ * them (a caption rule once rewrote '{"quotas":...}' inside a command to
1334
+ * '{`quotas`:...}'). Caption lines get the full rule set.
1335
+ * @param {string} text
1336
+ * @param {Object|null} customTransformations
1337
+ * @returns {string}
1338
+ */
1339
+ function applyTextTransformationsToExamples(text, customTransformations) {
1340
+ if (!text || !customTransformations?.replacements) return text
1341
+ return text.split('\n').map(line =>
1342
+ /^[ ]{2,}\S/.test(line)
1343
+ ? applyTextTransformations(line, customTransformations, { code: true })
1344
+ : applyTextTransformations(line, customTransformations)
1345
+ ).join('\n')
1346
+ }
1347
+
1257
1348
  /**
1258
1349
  * Format description by adding backticks around flags and code
1259
1350
  * @param {string} desc - Description text
@@ -1470,9 +1561,19 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1470
1561
  // Also handles '--flag/-f', '--flag help', and similar patterns
1471
1562
  .replace(/'(--[a-z][-a-z0-9]*(?:\/-[a-z])?(?:\s+\w+)?)'/gi, '$1')
1472
1563
  .replace(/'(-[a-z])'/gi, '$1')
1564
+ // Drop a dangling example lead-in with nothing after it (upstream help
1565
+ // strings sometimes end mid-example, which would render as "for example,.")
1566
+ .replace(/[,;]?\s*\be\.g\.[\s,]*$/i, '')
1567
+ // Stray space before a closing parenthesis (upstream help typo)
1568
+ .replace(/ +\)/g, ')')
1473
1569
  // === STYLE GUIDE COMPLIANCE ===
1474
- .replace(/\be\.g\.\s*/gi, 'for example, ')
1475
- .replace(/\bi\.e\.\s*/gi, 'that is, ')
1570
+ // "e.g.:" introduces a block; keep the colon instead of rendering ", :".
1571
+ // Horizontal whitespace only: crossing a newline would glue the next
1572
+ // line (often a code-block placeholder) onto this sentence.
1573
+ .replace(/,?[^\S\n]*\be\.g\.:[^\S\n]*/gi, ', for example: ')
1574
+ .replace(/, for example: $/gm, ', for example:')
1575
+ .replace(/\be\.g\.[^\S\n]*/gi, 'for example, ')
1576
+ .replace(/\bi\.e\.[^\S\n]*/gi, 'that is, ')
1476
1577
  // === TYPO CORRECTIONS ===
1477
1578
  // Fix "an" before consonant sounds (common source typos)
1478
1579
  .replace(/\ban\s+(prod|dev|test|local|remote|new|cluster|config|file)\b/gi, 'a $1')
@@ -1592,12 +1693,23 @@ function formatDescription(desc, customTransformations = null, options = {}) {
1592
1693
  // e.g., "(see #2904)" → "(see https://github.com/redpanda-data/redpanda/issues/2904[#2904])"
1593
1694
  result = result.replace(/(?<![`\w])#(\d{4,})(?![`\w])/g, 'https://github.com/redpanda-data/redpanda/issues/$1[#$1]')
1594
1695
 
1696
+ // Escape template placeholders like {name} in prose: Asciidoctor would
1697
+ // consume them as attribute references and drop them from the output.
1698
+ // Backtick spans can hold raw code at this stage, so only prose segments
1699
+ // between spans are touched. {vbar} stays: it is a real attribute used to
1700
+ // escape pipes in table cells.
1701
+ result = result.split(/(`[^`]*`)/).map((segment, idx) =>
1702
+ idx % 2 === 1 ? segment : segment.replace(/(?<![\\`\w{]){([a-z][\w-]{0,30})}/g, (match, token) =>
1703
+ token === 'vbar' ? match : `\\{${token}}`)
1704
+ ).join('')
1705
+
1595
1706
  // Restore early code blocks FIRST (from indented command/YAML detection)
1596
1707
  // This must happen before inline code restoration so that placeholders
1597
1708
  // inside the early code blocks (like __INLINE_CODE_X__) get resolved
1598
1709
  // Use function replacements to prevent $ special-pattern interpretation (e.g. `$` → before-match)
1599
1710
  earlyCodeBlocks.forEach((block, i) => {
1600
- result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => block)
1711
+ const transformed = applyTextTransformations(block, customTransformations, { code: true })
1712
+ result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => transformed)
1601
1713
  })
1602
1714
 
1603
1715
  // Restore inline code
@@ -1807,11 +1919,37 @@ function parseDescriptionSections(desc) {
1807
1919
  let currentContent = []
1808
1920
  const mainLines = []
1809
1921
 
1810
- for (const line of lines) {
1922
+ let skipNext = false
1923
+ for (let i = 0; i < lines.length; i++) {
1924
+ const line = lines[i]
1925
+ if (skipNext) {
1926
+ skipNext = false
1927
+ continue
1928
+ }
1811
1929
  // Check for ALL CAPS header (at least 2 chars, possibly with spaces, hyphens, slashes, or ampersands)
1812
1930
  // Examples: "FIELDS", "BALANCER STATUS", "PRODUCER ID & EPOCH"
1813
- const headerMatch = line.match(/^([A-Z][A-Z\s\-\/&]{0,}[A-Z])$/)
1931
+ // A line with runs of multiple spaces is a column-header row of aligned
1932
+ // sample output (for example "PARTITION REASON"), not a section
1933
+ // header, so leave it in the content.
1934
+ let headerMatch = / /.test(line) ? null : line.match(/^([A-Z][A-Z\s\-\/&]{0,}[A-Z])$/)
1935
+ // An all-caps line directly after a "$ command" invocation is the title
1936
+ // of that command's sample output (for example "DECOMMISSION PROGRESS"),
1937
+ // not a section header. Splitting there would strand the invocation in
1938
+ // one section and its output table in another.
1939
+ if (headerMatch) {
1940
+ for (let p = i - 1; p >= 0; p--) {
1941
+ if (lines[p].trim() === '') break
1942
+ if (/^\$\s/.test(lines[p])) { headerMatch = null; break }
1943
+ }
1944
+ }
1814
1945
  if (headerMatch) {
1946
+ // Help text often underlines section titles with a run of = or -
1947
+ // characters. Consume the underline: left in the content, a line of
1948
+ // 4+ = or - is an AsciiDoc block delimiter and breaks the page
1949
+ // (unterminated example block).
1950
+ if (i + 1 < lines.length && /^\s*(={3,}|-{3,})\s*$/.test(lines[i + 1])) {
1951
+ skipNext = true
1952
+ }
1815
1953
  // Save previous section
1816
1954
  if (currentSection) {
1817
1955
  // Preserve indentation: only remove leading/trailing blank lines, not spaces
@@ -1832,6 +1970,13 @@ function parseDescriptionSections(desc) {
1832
1970
  sections[currentSection] = trimBlankLines(currentContent.join('\n'))
1833
1971
  }
1834
1972
 
1973
+ const delimiterRun = /^\s*(={4,}|-{4,})\s*$/
1974
+ for (const [name, body] of Object.entries(sections)) {
1975
+ if (body.split('\n').some(l => delimiterRun.test(l))) {
1976
+ console.warn(`Warning: section "${name}" contains a bare =/- delimiter run; it may break AsciiDoc rendering`)
1977
+ }
1978
+ }
1979
+
1835
1980
  return {
1836
1981
  mainDescription: mainLines.join('\n').trim(),
1837
1982
  sections
@@ -1941,6 +2086,45 @@ function dashify(commandPath) {
1941
2086
  * @param {string} desc - Full description
1942
2087
  * @returns {string} Short description
1943
2088
  */
2089
+ /**
2090
+ * Collapse runs of three or more newlines to a single blank line, except
2091
+ * inside AsciiDoc delimited blocks (----, ====, ...., |===, and -- open
2092
+ * blocks), where blank lines are content.
2093
+ * @param {string} text - Rendered page content
2094
+ * @returns {string}
2095
+ */
2096
+ function collapseBlankLines(text) {
2097
+ const lines = text.split('\n')
2098
+ const out = []
2099
+ let inBlock = false
2100
+ let blockDelim = null
2101
+ let blankRun = 0
2102
+
2103
+ for (const line of lines) {
2104
+ const trimmed = line.trimEnd()
2105
+ const isDelim = /^(-{4,}|={4,}|\.{4,}|\|===|--)$/.test(trimmed)
2106
+ if (isDelim) {
2107
+ if (!inBlock) {
2108
+ inBlock = true
2109
+ blockDelim = trimmed
2110
+ } else if (trimmed === blockDelim || (blockDelim === '|===' && trimmed === '|===')) {
2111
+ inBlock = false
2112
+ blockDelim = null
2113
+ }
2114
+ }
2115
+
2116
+ if (!inBlock && trimmed === '') {
2117
+ blankRun++
2118
+ if (blankRun > 1) continue
2119
+ } else {
2120
+ blankRun = 0
2121
+ }
2122
+ out.push(line)
2123
+ }
2124
+
2125
+ return out.join('\n')
2126
+ }
2127
+
1944
2128
  function capToTwoSentences(desc) {
1945
2129
  if (!desc) return ''
1946
2130
  if (typeof desc !== 'string') return String(desc)
@@ -1949,6 +2133,19 @@ function capToTwoSentences(desc) {
1949
2133
  // Pattern: matches == Section Header and everything after it
1950
2134
  let cleaned = desc.replace(/\s*==\s+.+$/s, '')
1951
2135
 
2136
+ // Delimited code/output blocks never belong in a summary. Indented help
2137
+ // examples are converted to [,text]/[,bash]/[.no-copy] blocks before this
2138
+ // runs. A block introduced by a colon ends the summary there (the prose
2139
+ // after it would read as a non sequitur without the sample); otherwise
2140
+ // drop the block and keep the surrounding prose.
2141
+ const blockRe = /\n?\[[^\]\n]*\]\n----\n[\s\S]*?\n----|\n----\n[\s\S]*?\n----/
2142
+ const firstBlock = cleaned.match(blockRe)
2143
+ if (firstBlock && /:\s*$/.test(cleaned.slice(0, firstBlock.index))) {
2144
+ cleaned = cleaned.slice(0, firstBlock.index)
2145
+ } else {
2146
+ cleaned = cleaned.replace(new RegExp(blockRe.source, 'g'), '')
2147
+ }
2148
+
1952
2149
  // Remove indented content (tables, lists, etc.) after colons before sentence detection
1953
2150
  // Pattern: text ending with colon, optionally followed by blank line, then indented content
1954
2151
  // Examples:
@@ -1982,11 +2179,26 @@ function capToTwoSentences(desc) {
1982
2179
  })
1983
2180
  }
1984
2181
 
2182
+ // Protect decimal points in version-like numbers (OAuth 2.0, HTTP 1.1) so
2183
+ // they neither split a sentence nor cause the leading fragment to be
2184
+ // dropped by the sentence matcher
2185
+ normalized = normalized.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')
2186
+
1985
2187
  // Match sentences
1986
- const sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
2188
+ let sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
2189
+
2190
+ // Never drop a leading fragment: if the first matched sentence does not
2191
+ // start at the beginning of the text (an unterminated prefix was skipped),
2192
+ // glue the prefix back onto it
2193
+ if (sentences && sentences.length > 0) {
2194
+ const firstIdx = normalized.indexOf(sentences[0])
2195
+ if (firstIdx > 0) {
2196
+ sentences = [normalized.slice(0, firstIdx) + sentences[0], ...sentences.slice(1)]
2197
+ }
2198
+ }
1987
2199
  if (!sentences || sentences.length === 0) {
1988
2200
  // Restore and return
1989
- let result = normalized
2201
+ let result = normalized.replace(/__DECIMAL__/g, '.')
1990
2202
  placeholders.forEach(({ ph, original }) => {
1991
2203
  result = result.replace(ph, original)
1992
2204
  })
@@ -1998,6 +2210,9 @@ function capToTwoSentences(desc) {
1998
2210
 
1999
2211
  let result = sentences.slice(0, 2).join('')
2000
2212
 
2213
+ // Restore decimal points
2214
+ result = result.replace(/__DECIMAL__/g, '.')
2215
+
2001
2216
  // Restore abbreviations
2002
2217
  placeholders.forEach(({ ph, original }) => {
2003
2218
  result = result.replace(ph, original)
@@ -2087,6 +2302,61 @@ function findTopLevelWithSubcommands(tree) {
2087
2302
  return result
2088
2303
  }
2089
2304
 
2305
+ /**
2306
+ * Known rpk plugins that are managed separately from rpk core (they have
2307
+ * install/uninstall/upgrade shim commands, and their real command tree only
2308
+ * appears when the plugin binary is installed in the generation environment).
2309
+ * Shared with rpk-docs-handler.js, which iterates this list to install each
2310
+ * plugin before running --print-tree.
2311
+ */
2312
+ const KNOWN_PLUGINS = ['ai', 'check', 'connect', 'k8s', 'oxla']
2313
+
2314
+ /**
2315
+ * Subcommands that rpk core ships as a built-in shim for managed plugins,
2316
+ * present even when the plugin binary itself is not installed.
2317
+ */
2318
+ const PLUGIN_SHIM_COMMANDS = new Set(['install', 'uninstall', 'upgrade'])
2319
+
2320
+ /**
2321
+ * Check whether a command path belongs to a protected plugin's subtree
2322
+ * (including the plugin's own top-level command).
2323
+ * @param {string} commandPath - Full command path, e.g. 'rpk k8s multicluster'
2324
+ * @param {Array<string>} protectedPlugins - Plugin names, e.g. ['k8s']
2325
+ * @returns {boolean}
2326
+ */
2327
+ function isProtectedCommandPath(commandPath, protectedPlugins) {
2328
+ return protectedPlugins.some(pl => commandPath === `rpk ${pl}` || commandPath.startsWith(`rpk ${pl} `))
2329
+ }
2330
+
2331
+ /**
2332
+ * Determine which managed plugins must be protected for this run.
2333
+ *
2334
+ * Auto-detects plugins whose commands are missing from this run's tree:
2335
+ * rpk core ships only a shim (install/uninstall/upgrade) for managed
2336
+ * plugins; the real commands appear only when the plugin binary is
2337
+ * installed in the generation environment. If a known plugin's subtree is
2338
+ * absent or shim-only, its existing pages reflect the plugin, not stale
2339
+ * commands, so they must be preserved. Pre-GA windows hit this every time:
2340
+ * the plugin publisher only promotes stable X.Y.Z releases, so `rpk <plugin>
2341
+ * install` finds nothing until GA (see redpanda-data/docs#1831).
2342
+ *
2343
+ * @param {Array} commands - Flat command list from flattenCommands()
2344
+ * @param {Array<string>} explicitProtected - Plugins the caller already knows
2345
+ * failed to install this run (merged with auto-detection)
2346
+ * @returns {Array<string>} Deduplicated protected plugin names
2347
+ */
2348
+ function detectProtectedPlugins(commands, explicitProtected = []) {
2349
+ const autoProtected = KNOWN_PLUGINS.filter(pl => {
2350
+ const subNames = commands
2351
+ .filter(c => c.path.startsWith(`rpk ${pl} `))
2352
+ .map(c => c.path.split(' ')[2])
2353
+ const hasTopLevel = commands.some(c => c.path === `rpk ${pl}`)
2354
+ if (!hasTopLevel) return true
2355
+ return subNames.length === 0 || subNames.every(n => PLUGIN_SHIM_COMMANDS.has(n))
2356
+ })
2357
+ return [...new Set([...explicitProtected, ...autoProtected])]
2358
+ }
2359
+
2090
2360
  /**
2091
2361
  * Static entries that always appear at the top of the rpk nav section,
2092
2362
  * before the auto-generated command entries. These are hand-written pages
@@ -2095,6 +2365,10 @@ function findTopLevelWithSubcommands(tree) {
2095
2365
  * landing page (the section header), so it is skipped during generation.
2096
2366
  */
2097
2367
  const STATIC_RPK_NAV_ENTRIES = [
2368
+ // The root rpk command page is generated but skipped by the entry loop
2369
+ // (its path has no group segment), so it is listed here to keep it in
2370
+ // the nav ahead of the hand-written pages.
2371
+ '*** xref:reference:rpk/rpk.adoc[]',
2098
2372
  '*** xref:reference:rpk/rpk-commands.adoc[]',
2099
2373
  '*** xref:reference:rpk/rpk-x-options.adoc[rpk -X]',
2100
2374
  ]
@@ -2109,7 +2383,7 @@ const STATIC_RPK_NAV_ENTRIES = [
2109
2383
  * @param {Set} topLevelWithSubcommands - Set of top-level command names with subcommands
2110
2384
  * @returns {Object} { navUpdated, navEntriesGenerated }
2111
2385
  */
2112
- function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands) {
2386
+ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands, protectedPlugins = []) {
2113
2387
  if (!fs.existsSync(navFile)) {
2114
2388
  console.warn(`Warning: nav file not found, skipping nav update: ${navFile}`)
2115
2389
  return { navUpdated: false }
@@ -2140,6 +2414,10 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2140
2414
  const newEntries = []
2141
2415
  for (const { path: commandPath } of commands) {
2142
2416
  if (commandPath === 'rpk') continue
2417
+ // Protected plugins get no generated entries: this run's tree has at
2418
+ // most the shim (install/uninstall/upgrade) for them, so their previous
2419
+ // nav block is preserved in place instead (below).
2420
+ if (isProtectedCommandPath(commandPath, protectedPlugins)) continue
2143
2421
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) continue
2144
2422
  if (shouldUsePartialDir(resolvedOverrides, commandPath)) continue
2145
2423
  if (commandPath.startsWith('rpk cloud')) continue
@@ -2155,12 +2433,65 @@ function updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcomm
2155
2433
  newEntries.push(`${stars} xref:${xrefPath}[]`)
2156
2434
  }
2157
2435
 
2158
- // Rebuild: header + static entries + generated entries
2159
- const newSection = [
2160
- lines[sectionStart],
2161
- ...STATIC_RPK_NAV_ENTRIES,
2162
- ...newEntries,
2163
- ]
2436
+ // Preserve existing entries for protected plugins. Their commands are
2437
+ // absent (or shim-only) in this run's tree, so nothing was generated for
2438
+ // them above. Each plugin's previous nav block (parent entry plus nested
2439
+ // children) is kept together and spliced back in at its original position,
2440
+ // anchored to the nearest preceding entry that survives regeneration —
2441
+ // never appended at the end, where the children would render under the
2442
+ // wrong parent.
2443
+ const stripStars = (line) => line.replace(/^\*+ /, '')
2444
+ const emittedTargets = new Set([...STATIC_RPK_NAV_ENTRIES, ...newEntries].map(stripStars))
2445
+ // Map of anchor target (or null for "before all surviving entries") to the
2446
+ // preserved lines that follow that anchor, in original order.
2447
+ const preservedBlocks = new Map()
2448
+ let preservedCount = 0
2449
+ const preservedPlugins = new Set()
2450
+ if (protectedPlugins.length > 0) {
2451
+ const patternsFor = (pl) => [`reference:rpk/rpk-${pl}/`, `reference:rpk/rpk-${pl}.adoc`]
2452
+ const pluginFor = (line) => protectedPlugins.find(pl => patternsFor(pl).some(pat => line.includes(pat)))
2453
+ let lastAnchor = null
2454
+ for (const line of lines.slice(sectionStart + 1, sectionEnd)) {
2455
+ const plugin = pluginFor(line)
2456
+ if (plugin) {
2457
+ // Never duplicate an entry that regeneration already emits.
2458
+ if (emittedTargets.has(stripStars(line))) continue
2459
+ if (!preservedBlocks.has(lastAnchor)) preservedBlocks.set(lastAnchor, [])
2460
+ preservedBlocks.get(lastAnchor).push(line)
2461
+ preservedCount++
2462
+ preservedPlugins.add(plugin)
2463
+ } else if (emittedTargets.has(stripStars(line))) {
2464
+ lastAnchor = stripStars(line)
2465
+ }
2466
+ }
2467
+ if (preservedCount > 0) {
2468
+ console.log(` Preserving ${preservedCount} nav entries for plugins: ${[...preservedPlugins].join(', ')}`)
2469
+ }
2470
+ }
2471
+
2472
+ // Rebuild: header + static entries + generated entries, splicing each
2473
+ // preserved block back in directly after its anchor entry.
2474
+ const newSection = [lines[sectionStart]]
2475
+ const pushEntry = (line) => {
2476
+ newSection.push(line)
2477
+ const target = stripStars(line)
2478
+ if (preservedBlocks.has(target)) {
2479
+ newSection.push(...preservedBlocks.get(target))
2480
+ preservedBlocks.delete(target)
2481
+ }
2482
+ }
2483
+ // Blocks that preceded every surviving entry in the old nav go right after
2484
+ // the section header, before the static entries.
2485
+ if (preservedBlocks.has(null)) {
2486
+ newSection.push(...preservedBlocks.get(null))
2487
+ preservedBlocks.delete(null)
2488
+ }
2489
+ for (const line of STATIC_RPK_NAV_ENTRIES) pushEntry(line)
2490
+ for (const line of newEntries) pushEntry(line)
2491
+ // Safety net: if an anchor vanished between collection and rebuild (it
2492
+ // cannot with the logic above, but never silently drop preserved entries),
2493
+ // append any remaining blocks at the end.
2494
+ for (const block of preservedBlocks.values()) newSection.push(...block)
2164
2495
 
2165
2496
  const newLines = [
2166
2497
  ...lines.slice(0, sectionStart),
@@ -2187,7 +2518,12 @@ async function generateRpkDocs(options = {}) {
2187
2518
  pluginVersions = {},
2188
2519
  draftMissing = false,
2189
2520
  flatOutput = false, // If true, output all files flat (legacy behavior)
2190
- navFile // Optional: path to nav.adoc for automatic nav updates
2521
+ navFile, // Optional: path to nav.adoc for automatic nav updates
2522
+ // Plugins that exist but could not be installed for this run (for example,
2523
+ // rpk k8s before its GA plugin publishes). Their pages and nav entries are
2524
+ // preserved instead of treated as stale, because their absence from the
2525
+ // command tree reflects the generation environment, not the product.
2526
+ protectedPlugins = []
2191
2527
  } = options
2192
2528
 
2193
2529
  // Register partials
@@ -2225,6 +2561,16 @@ async function generateRpkDocs(options = {}) {
2225
2561
  // Flatten command tree
2226
2562
  const commands = flattenCommands(tree)
2227
2563
 
2564
+ // Determine which managed plugins are protected this run: explicitly
2565
+ // passed by the caller (failed installs) merged with auto-detection of
2566
+ // absent or shim-only plugin subtrees. Protection covers the whole
2567
+ // pipeline — page writes, the stale-file sweep, and nav updates — so a
2568
+ // run without the plugin binary leaves the plugin's docs fully untouched.
2569
+ const effectiveProtectedPlugins = detectProtectedPlugins(commands, protectedPlugins)
2570
+ if (effectiveProtectedPlugins.length > 0) {
2571
+ console.log(` Protected plugins this run (pages and nav preserved, not regenerated): ${effectiveProtectedPlugins.join(', ')}`)
2572
+ }
2573
+
2228
2574
  // Find top-level commands with subcommands (for directory structure)
2229
2575
  const topLevelWithSubcommands = findTopLevelWithSubcommands(tree)
2230
2576
 
@@ -2246,6 +2592,18 @@ async function generateRpkDocs(options = {}) {
2246
2592
  continue
2247
2593
  }
2248
2594
 
2595
+ // Protected plugins: skip generating the whole subtree, parent page
2596
+ // included. This run's tree has at most the shim
2597
+ // (install/uninstall/upgrade) for these plugins, so regenerating would
2598
+ // overwrite full-plugin pages from an earlier successful run — for
2599
+ // example, rewriting rpk-k8s.adoc with a Subcommands table reduced to
2600
+ // the shim and orphaning the preserved child pages. Existing pages stay
2601
+ // untouched, exactly as in the fully-absent-plugin case.
2602
+ if (isProtectedCommandPath(commandPath, effectiveProtectedPlugins)) {
2603
+ filesSkipped++
2604
+ continue
2605
+ }
2606
+
2249
2607
  // Check if command should be excluded
2250
2608
  if (shouldExcludeCommand(resolvedOverrides, commandPath)) {
2251
2609
  filesSkipped++
@@ -2273,8 +2631,9 @@ async function generateRpkDocs(options = {}) {
2273
2631
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2274
2632
  examplesContent = filterExamples(sectionContent, mergedCommand.excludeExamples)
2275
2633
  }
2276
- // Apply text transformations (e.g. rpai → rpk ai) then format
2277
- examplesContent = applyTextTransformations(examplesContent, textTransformations)
2634
+ // Apply text transformations (e.g. rpai → rpk ai) then format.
2635
+ // Command lines only get code-safe rules.
2636
+ examplesContent = applyTextTransformationsToExamples(examplesContent, textTransformations)
2278
2637
  sections[sectionName] = formatExamples(examplesContent)
2279
2638
  } else {
2280
2639
  sections[sectionName] = formatDescription(sectionContent, textTransformations)
@@ -2291,8 +2650,9 @@ async function generateRpkDocs(options = {}) {
2291
2650
  if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2292
2651
  examplesContent = filterExamples(examplesContent, mergedCommand.excludeExamples)
2293
2652
  }
2294
- // Apply text transformations (e.g. rpai → rpk ai) then format
2295
- examplesContent = applyTextTransformations(examplesContent, textTransformations)
2653
+ // Apply text transformations (e.g. rpai → rpk ai) then format.
2654
+ // Command lines only get code-safe rules.
2655
+ examplesContent = applyTextTransformationsToExamples(examplesContent, textTransformations)
2296
2656
  sections.EXAMPLES = formatExamples(examplesContent)
2297
2657
  }
2298
2658
 
@@ -2365,9 +2725,16 @@ async function generateRpkDocs(options = {}) {
2365
2725
  // Build subcommands with correct xref paths
2366
2726
  // Filter out excluded and asPartial subcommands — excluded have no file,
2367
2727
  // asPartial ones live in the partials directory with no linkable xref.
2728
+ // rpk cloud and rpk security secret are hardcoded-routed to partials
2729
+ // (single-sourced into cloud docs), so they have no linkable pages
2730
+ // either: rpk-security.adoc linking rpk-security-secret.adoc was a
2731
+ // broken xref in the published site.
2368
2732
  const subcommands = (command.commands || [])
2369
2733
  .filter(sub => {
2370
2734
  const subPath = `${commandPath} ${sub.name}`
2735
+ if (subPath.startsWith('rpk cloud') || subPath.startsWith('rpk security secret')) {
2736
+ return false
2737
+ }
2371
2738
  return !shouldExcludeCommand(resolvedOverrides, subPath) && !shouldUsePartialDir(resolvedOverrides, subPath)
2372
2739
  })
2373
2740
  .map(sub => {
@@ -2431,7 +2798,24 @@ async function generateRpkDocs(options = {}) {
2431
2798
  return `${parentPath} ${alias}`
2432
2799
  }),
2433
2800
  aliasNotes: commandOverride.aliasNotes,
2434
- flags: (mergedCommand.flags || []).map(flag => ({
2801
+ // A curated override section titled "Flags" wins over the extracted
2802
+ // flag table: rendering both produced duplicate == Flags headings and
2803
+ // conflicting content (rpk connect run). Curation is authoritative;
2804
+ // the warning tells maintainers the override can be dropped to adopt
2805
+ // the extracted table.
2806
+ flags: (() => {
2807
+ const hasCuratedFlagsSection = Object.values(processedContent.sections || {})
2808
+ .some(items => (items || []).some(item => /^flags$/i.test(item.title || '')))
2809
+ if (hasCuratedFlagsSection && (mergedCommand.flags || []).length > 0) {
2810
+ console.warn(
2811
+ `Warning: ${commandPath} has a curated "Flags" override section; ` +
2812
+ `skipping the extracted flag table (${mergedCommand.flags.length} flags). ` +
2813
+ 'Remove the override section to adopt the extracted table.'
2814
+ )
2815
+ return []
2816
+ }
2817
+ return mergedCommand.flags || []
2818
+ })().map(flag => ({
2435
2819
  ...flag,
2436
2820
  name: flag.shorthand ? `-${flag.shorthand}, --${flag.name}` : `--${flag.name}`,
2437
2821
  type: flag.type || '-',
@@ -2542,8 +2926,26 @@ async function generateRpkDocs(options = {}) {
2542
2926
  hasUnknownSections: unknownSections.length > 0
2543
2927
  }
2544
2928
 
2545
- // Render template
2546
- const content = template(context)
2929
+ // Render template, then collapse runs of blank lines left behind by
2930
+ // absent optional sections (plugin commands have no aliases or flags, so
2931
+ // the separators between skipped blocks stack up). Blank lines inside
2932
+ // delimited blocks are preserved: they are content there.
2933
+ const content = collapseBlankLines(template(context))
2934
+
2935
+ // Duplicate top-level headings almost always mean an override adds a
2936
+ // section the page already renders (or embeds its own heading in raw
2937
+ // content). The page still builds, but anchors collide and readers see
2938
+ // the same section twice.
2939
+ const h2Counts = new Map()
2940
+ for (const line of content.split('\n')) {
2941
+ const h2 = line.match(/^== (\S.*)$/)
2942
+ if (h2) h2Counts.set(h2[1], (h2Counts.get(h2[1]) || 0) + 1)
2943
+ }
2944
+ for (const [heading, count] of h2Counts) {
2945
+ if (count > 1) {
2946
+ console.warn(`⚠️ ${commandPath}: heading "== ${heading}" appears ${count} times. Check the override content for this command.`)
2947
+ }
2948
+ }
2547
2949
 
2548
2950
  // Determine output path
2549
2951
  // Check if this command should go to cloudSecretDir
@@ -2614,9 +3016,19 @@ async function generateRpkDocs(options = {}) {
2614
3016
  .map(entry => path.join(baseDir, entry.name))
2615
3017
  }
2616
3018
 
3019
+ // Protected plugins (computed before the write loop, see
3020
+ // detectProtectedPlugins): their existing pages reflect the plugin, not
3021
+ // stale commands, so their directories are excluded from the sweep.
3022
+ const protectedDirNames = new Set(effectiveProtectedPlugins.map(pl => `rpk-${pl}`))
2617
3023
  const scanDirs = new Set()
2618
3024
  for (const dir of rpkSubdirs(outputDir)) scanDirs.add(dir)
2619
3025
  for (const dir of rpkSubdirs(cloudSecretDir)) scanDirs.add(dir)
3026
+ for (const dir of [...scanDirs]) {
3027
+ if (protectedDirNames.has(path.basename(dir))) {
3028
+ console.log(` Preserving ${path.basename(dir)}/ (plugin commands absent from this run's tree)`)
3029
+ scanDirs.delete(dir)
3030
+ }
3031
+ }
2620
3032
 
2621
3033
  for (const dir of scanDirs) {
2622
3034
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
@@ -2644,7 +3056,7 @@ async function generateRpkDocs(options = {}) {
2644
3056
  // Update nav.adoc if a path was provided
2645
3057
  let navResult = { navUpdated: false }
2646
3058
  if (navFile) {
2647
- navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands)
3059
+ navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands, effectiveProtectedPlugins)
2648
3060
  }
2649
3061
 
2650
3062
  return {
@@ -2681,9 +3093,12 @@ module.exports = {
2681
3093
  shouldExcludeCommand,
2682
3094
  shouldUsePartialDir,
2683
3095
  updateNavFile,
3096
+ KNOWN_PLUGINS,
3097
+ detectProtectedPlugins,
2684
3098
  getCommandMetadata,
2685
3099
  processContentArray,
2686
3100
  // Exported for testing
2687
3101
  filterExamples,
2688
- formatExamples
3102
+ formatExamples,
3103
+ applyTextTransformationsToExamples
2689
3104
  }