Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions __tests__/tools/rpk-docs/override-features.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,25 @@ Original fields content that should be replaced.`,
expect(page).toContain('|xref:reference:rpk/rpk-cluster/rpk-cluster-info.adoc[`rpk cluster info`]')
}, 30000)

// Availability never wraps the page itself: includes only extract the
// single-source tag region (a page-level conditional outside it is dead
// code), and a build that did consume it would publish an empty,
// untitled page. Row and flag gating inside the tag does the real work.
test('selfHostedOnly never wraps the command page in a conditional', async () => {
await generateRpkDocs({
tree: clusterTree(),
overrides: { commands: { 'rpk cluster health': { selfHostedOnly: true } } },
outputDir,
rpkVersion: 'test',
pluginVersions: {}
})

const page = fs.readFileSync(path.join(outputDir, 'rpk-cluster', 'rpk-cluster-health.adoc'), 'utf8')
expect(page.startsWith('= rpk cluster health')).toBe(true)
expect(page).not.toMatch(/^ifndef::env-cloud/m)
expect(page.trimEnd().endsWith('// end::single-source[]')).toBe(true)
}, 30000)

test('wraps cloudOnly subcommand rows in ifdef::env-cloud', async () => {
await generateRpkDocs({
tree: clusterTree(),
Expand Down
80 changes: 80 additions & 0 deletions __tests__/tools/rpk-docs/rpk-docs-handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const os = require('os')

const {
updateOverridesWithIntroducedVersions,
isPluginStampAttributable,
attributablePluginSet,
pluginManifestVersionsCache,
detectLinuxOnlyFromSource,
addPlatformMarkersFromSource,
countCommands,
Expand Down Expand Up @@ -105,6 +108,83 @@ describe('rpk Docs Handler', () => {
})
})

describe('introduction-version attribution', () => {
// Mirrors a real incident: 30 rpk ai commands shipped in 0.2.26 and
// 0.2.28 were stamped "introduced in 0.2.32" because the baseline
// snapshot was several plugin releases stale.
afterEach(() => pluginManifestVersionsCache.clear())

test('skips plugin entries when the plugin is not attributable, stamps core', () => {
fs.writeFileSync(overridesPath, JSON.stringify({ commands: {} }))

const diffData = {
summary: { newCommands: 2 },
details: {
newCommands: [
{ path: 'rpk ai policy create' },
{ path: 'rpk cluster new-command' }
],
newFlags: [{ commandPath: 'rpk ai policy', flagName: 'new-flag' }],
removedCommands: [],
removedFlags: [],
changedDefaults: []
}
}

updateOverridesWithIntroducedVersions(diffData, overridesPath, 'v26.2.1', { ai: '0.2.32' }, {
attributablePlugins: []
})

const result = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
expect(result.commands['rpk ai policy create']).toBeUndefined()
expect(result.commands['rpk ai policy']).toBeUndefined()
expect(result.commands['rpk cluster new-command'].introducedInVersion).toBe('v26.2.1')
})

test('stamps plugin entries when the plugin is attributable', () => {
fs.writeFileSync(overridesPath, JSON.stringify({ commands: {} }))

const diffData = {
summary: { newCommands: 1 },
details: {
newCommands: [{ path: 'rpk ai policy create' }],
newFlags: [],
removedCommands: [],
removedFlags: [],
changedDefaults: []
}
}

updateOverridesWithIntroducedVersions(diffData, overridesPath, 'v26.2.1', { ai: '0.2.32' }, {
attributablePlugins: ['ai']
})

const result = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
expect(result.commands['rpk ai policy create'].introducedInVersion).toBe('0.2.32')
})

test('isPluginStampAttributable requires a manifest-adjacent baseline', () => {
pluginManifestVersionsCache.set('ai', ['0.2.30', '0.2.31', '0.2.32'])
expect(isPluginStampAttributable('ai', '0.2.31', '0.2.32')).toBe(true)
expect(isPluginStampAttributable('ai', '0.2.32', '0.2.32')).toBe(true)
// A gap means intermediate releases may own the "new" commands
expect(isPluginStampAttributable('ai', '0.2.30', '0.2.32')).toBe(false)
// Unknown baseline can never be attributed
expect(isPluginStampAttributable('ai', undefined, '0.2.32')).toBe(false)
})

test('attributablePluginSet evaluates each plugin independently', () => {
pluginManifestVersionsCache.set('ai', ['0.2.31', '0.2.32'])
pluginManifestVersionsCache.set('connect', ['4.101.0', '4.102.0', '4.103.1'])
const set = attributablePluginSet(
{ ai: '0.2.31', connect: '4.101.0' },
{ ai: '0.2.32', connect: '4.103.1' }
)
expect(set.has('ai')).toBe(true)
expect(set.has('connect')).toBe(false)
})
})

describe('flag version tracking', () => {
test('adds introducedInVersion for new flags', () => {
fs.writeFileSync(overridesPath, JSON.stringify({ commands: {} }))
Expand Down
93 changes: 93 additions & 0 deletions __tests__/tools/rpk-docs/text-transformations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,99 @@ describe('applyToCode rules in early code blocks', () => {
})
})

describe('applyToCode rules in inline code spans', () => {
const { formatDescription } = require('../../../tools/rpk-docs/generate-rpk-docs.js')

const transforms = {
replacements: [
{ pattern: '\\brpai\\b', replacement: 'rpk ai', flags: 'g', applyToCode: true },
{ pattern: '(^|\\n\\s*)Note:\\s', replacement: '$1NOTE: ', flags: 'g' }
]
}

test('rewrites the binary name inside protected inline code spans', () => {
const out = formatDescription('Run `rpai auth token` to authenticate first.', transforms)
expect(out).toContain('`rpk ai auth token`')
expect(out).not.toContain('rpai')
})

test('rules without applyToCode never touch inline code spans', () => {
const out = formatDescription('The literal `Note: keep this` stays verbatim.', transforms)
expect(out).toContain('`Note: keep this`')
expect(out).not.toContain('NOTE: keep this')
})
})

describe('known command path formatting', () => {
const { formatDescription, registerKnownCommandPaths } = require('../../../tools/rpk-docs/generate-rpk-docs.js')

afterEach(() => registerKnownCommandPaths([]))

test('wraps a full multi-word command path as a unit', () => {
registerKnownCommandPaths(['rpk', 'rpk ai', 'rpk ai run', 'rpk ai run codex'])
const out = formatDescription('Use rpk ai run codex to start a session.', null)
expect(out).toContain('`rpk ai run codex` to start a session.')
expect(out).not.toContain('`rpk` ai')
})

test('prefers the longest registered path over a shorter prefix', () => {
registerKnownCommandPaths(['rpk', 'rpk ai', 'rpk ai run', 'rpk ai run claude'])
const out = formatDescription('Then rpk ai run claude resumes the session.', null)
expect(out).toContain('`rpk ai run claude` resumes')
})

test('leaves prose that resembles a command alone when not in the tree', () => {
registerKnownCommandPaths(['rpk', 'rpk cloud'])
const out = formatDescription('Manage rpk cloud authentications for details.', null)
// A registered single-token prefix ("rpk cloud") never matches: the
// known-path pass requires two tokens after rpk, so the phrase falls
// through to the context-aware heuristic, which wraps rpk alone.
expect(out).toBe('Manage `rpk` cloud authentications for details.')
})

test('is inert when no paths are registered', () => {
const out = formatDescription('Use rpk ai run codex to start.', null)
expect(out).not.toContain('`rpk ai run codex`')
})
})

describe('mid-token periods in summaries', () => {
const { formatDescription, capToTwoSentences } = require('../../../tools/rpk-docs/generate-rpk-docs.js')

test('dotted topic names never split or drop a sentence', () => {
const src = "View logs for a transform.\n\nData transform's STDOUT and STDERR are captured during runtime and written to \nan internally managed topic _redpanda.transform_logs.\nThis command outputs logs for a single transform."
const out = capToTwoSentences(formatDescription(src, null, { skipTableConversion: true, skipListConversion: true }))
expect(out).not.toBe('View logs for a transform. transform_logs.')
expect(out).toContain('`_redpanda.transform_logs`')
expect(out).toContain('STDOUT and STDERR are captured')
})

test('URLs never split or drop a sentence', () => {
const out = capToTwoSentences('Generate a license. To get one, contact us at redpanda.com/contact for details. The license is saved locally.')
expect(out).toBe('Generate a license. To get one, contact us at redpanda.com/contact for details.')
})

test('an unterminated paragraph is a sentence boundary', () => {
const out = capToTwoSentences('Generate a trial license\n\nThis command generates a license for a 30-day trial. The license is saved locally.')
expect(out).toBe('Generate a trial license. This command generates a license for a 30-day trial.')
})
})

describe('internal topic name backticking', () => {
const { formatDescription } = require('../../../tools/rpk-docs/generate-rpk-docs.js')

test('wraps _redpanda.* topic names in inline code', () => {
const out = formatDescription('Logs are written to an internally managed topic _redpanda.transform_logs.\nRead them with the logs command.', null)
expect(out).toContain('`_redpanda.transform_logs`.')
})

test('leaves already-backticked topic names alone', () => {
const out = formatDescription('Logs go to `_redpanda.transform_logs` always.', null)
expect(out).toContain('`_redpanda.transform_logs`')
expect(out).not.toContain('``')
})
})

describe('applyTextTransformationsToExamples', () => {
const { applyTextTransformationsToExamples } = require('../../../tools/rpk-docs/generate-rpk-docs.js')

Expand Down
2 changes: 1 addition & 1 deletion docs-data/rpk-overrides.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
},
"applyToCode": {
"type": "boolean",
"description": "Also apply this replacement inside code blocks captured from help text (verbatim command examples). Default false: code blocks are protected from prose transformations. Use for binary-name rewrites like rpai to rpk ai."
"description": "Also apply this replacement inside code contexts: code blocks captured from help text (verbatim command examples) and inline code spans. Default false: code contexts are protected from prose transformations. Use for binary-name rewrites like rpai to rpk ai."
}
},
"required": ["pattern", "replacement"],
Expand Down
67 changes: 64 additions & 3 deletions tools/rpk-docs/generate-rpk-docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ Object.entries(helpers).forEach(([name, fn]) => {
// Template paths
const TEMPLATES_DIR = path.resolve(__dirname, './templates')

// Full command paths from the tree being generated (e.g. "rpk ai run codex").
// Registered by generateRpkDocs so formatDescription can wrap real multi-word
// command paths as a unit instead of heuristically wrapping `rpk` alone.
let knownCommandPaths = new Set()

/**
* Register the set of real command paths for the current generation run.
* @param {string[]} paths - Full command paths (e.g. "rpk ai run codex")
*/
function registerKnownCommandPaths(paths) {
knownCommandPaths = new Set(paths)
}

/**
* Register a Handlebars partial from file
* @param {string} name - Partial name
Expand Down Expand Up @@ -1607,6 +1620,25 @@ function formatDescription(desc, customTransformations = null, options = {}) {
// Fix product name: "Redpanda cloud" → "Redpanda Cloud" (product name)
.replace(/Redpanda\s+cloud\b/g, 'Redpanda Cloud')

// === RPK COMMAND FORMATTING (known paths first, ground truth) ===
// Wrap full multi-word command paths that exist in the generated tree
// (e.g. "rpk ai run codex" -> `rpk ai run codex`). The heuristic formatter
// below only ever matches "rpk <word>", so without this pass it wraps
// `rpk` alone and splits the command in half. Matching against the real
// tree means prose that merely resembles a command is never wrapped.
if (knownCommandPaths.size > 0) {
result = result.replace(/(?<![`\w])rpk((?: [a-z][-a-z0-9]*)+)/g, (match, rest) => {
const tokens = rest.trim().split(' ')
for (let n = tokens.length; n >= 2; n--) {
const path = `rpk ${tokens.slice(0, n).join(' ')}`
if (knownCommandPaths.has(path)) {
return `\`${path}\`` + match.slice(path.length)
}
}
return match
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}

// === RPK COMMAND FORMATTING (context-aware) ===
// Process "rpk X" patterns based on what follows
result = result.replace(/(?<![`\w])rpk(\s+)([a-z][-a-z0-9]*)(?:\s+|(?=[.,;:!?)'"}\]]|$))/gi, (match, space, word, offset, str) => {
Expand Down Expand Up @@ -1702,6 +1734,11 @@ function formatDescription(desc, customTransformations = null, options = {}) {
// Add backticks around standalone file names (not inside paths)
result = result.replace(/(?<![`/])(redpanda\.yaml|rpk\.yaml)(?!`)/gi, '`$1`')

// Add backticks around internal topic names (_redpanda.transform_logs,
// _redpanda.audit_log, ...). The leading underscore is also an AsciiDoc
// italics delimiter, so these must never render as bare prose.
result = result.replace(/(?<![`\w/])(_redpanda(?:\.[a-z_]+)+)(?!`)/g, '`$1`')

// Add backticks around standalone "rpk" (at end of phrase, before punctuation)
// Also exclude rpk inside paths (preceded by /)
result = result.replace(/(?<![`\w/])rpk(?=[\s]*[.,;:!?)'"}\]]|[\s]*$)/g, '`rpk`')
Expand Down Expand Up @@ -1730,9 +1767,13 @@ function formatDescription(desc, customTransformations = null, options = {}) {
result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, () => transformed)
})

// Restore inline code
// Restore inline code. Spans are verbatim, but rules flagged applyToCode
// (like the rpai -> rpk ai binary-name rewrite) must reach them the same
// way they reach code blocks, or the internal binary name survives in
// published spans like `rpai auth token`.
inlineCode.forEach((code, i) => {
result = result.replace(`__INLINE_CODE_${i}__`, () => code)
const transformed = applyTextTransformations(code, customTransformations, { code: true })
result = result.replace(`__INLINE_CODE_${i}__`, () => transformed)
})

// Restore xrefs
Expand Down Expand Up @@ -2173,6 +2214,12 @@ function capToTwoSentences(desc) {
// The pattern matches: colon, optional whitespace/newlines, then indented content
cleaned = cleaned.replace(/:\s*\n+[ \t]+.+$/s, ':')

// A paragraph break is a sentence boundary even when the paragraph has no
// terminal punctuation (cobra short descriptions often lack one: "Generate
// a trial license\n\nThis command..."). Without this, flattening newlines
// glues the paragraphs into one run-on "sentence".
cleaned = cleaned.replace(/([^.!?:\s])[ \t]*\n[ \t]*\n/g, '$1.\n\n')

// Normalize newlines to spaces for inline use (like :description: attribute)
const singleLine = cleaned.replace(/\s*\n+\s*/g, ' ').trim()

Expand Down Expand Up @@ -2202,6 +2249,14 @@ function capToTwoSentences(desc) {
// dropped by the sentence matcher
normalized = normalized.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')

// Protect ALL mid-token periods (no whitespace after): dotted names like
// _redpanda.transform_logs or URLs like redpanda.com/contact are not
// sentence boundaries. Without this the sentence matcher below cannot
// match the sentence containing the token, silently drops everything up
// to the mid-token period, and emits the tail fragment as a "sentence"
// ("View logs for a transform. transform_logs.").
normalized = normalized.replace(/\.(?=\S)/g, '__MIDDOT__')

// Match sentences
let sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)

Expand All @@ -2217,6 +2272,7 @@ function capToTwoSentences(desc) {
if (!sentences || sentences.length === 0) {
// Restore and return
let result = normalized.replace(/__DECIMAL__/g, '.')
result = result.replace(/__MIDDOT__/g, '.')
placeholders.forEach(({ ph, original }) => {
result = result.replace(ph, original)
})
Expand All @@ -2230,6 +2286,7 @@ function capToTwoSentences(desc) {

// Restore decimal points
result = result.replace(/__DECIMAL__/g, '.')
result = result.replace(/__MIDDOT__/g, '.')

// Restore abbreviations
placeholders.forEach(({ ph, original }) => {
Expand Down Expand Up @@ -2578,6 +2635,9 @@ async function generateRpkDocs(options = {}) {
// Flatten command tree
const commands = flattenCommands(tree)

// Let formatDescription wrap real multi-word command paths as a unit
registerKnownCommandPaths(commands.map(c => c.path))

// Determine which managed plugins are protected this run: explicitly
// passed by the caller (failed installs) merged with auto-detection of
// absent or shim-only plugin subtrees. Protection covers the whole
Expand Down Expand Up @@ -3121,5 +3181,6 @@ module.exports = {
// Exported for testing
filterExamples,
formatExamples,
applyTextTransformationsToExamples
applyTextTransformationsToExamples,
registerKnownCommandPaths
}
Loading
Loading