@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.
- package/bin/doc-tools.js +159 -0
- package/package.json +1 -1
- package/tools/property-extractor/property_extractor.py +18 -1
- package/tools/property-extractor/transformers.py +29 -13
- package/tools/redpanda-connect/helpers/renderConnectFields.js +19 -7
- package/tools/rpk-docs/generate-plugin-stubs.js +291 -0
- package/tools/rpk-docs/generate-rpk-docs.js +440 -25
- package/tools/rpk-docs/helpers/index.js +13 -3
- package/tools/rpk-docs/report-delta.js +314 -32
- package/tools/rpk-docs/rpk-docs-handler.js +1230 -102
- package/tools/rpk-docs/templates/command.hbs +5 -0
- package/tools/rpk-docs/templates/content-item.hbs +2 -2
|
@@ -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
|
-
.
|
|
1475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|