@redpanda-data/docs-extensions-and-macros 5.2.5 → 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.
@@ -52,16 +52,26 @@ function shortDescription(str) {
52
52
  })
53
53
  }
54
54
 
55
- const sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
55
+ // Protect decimal points (OAuth 2.0) so version numbers neither split a
56
+ // sentence nor cause the leading fragment to be dropped
57
+ normalized = normalized.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')
58
+
59
+ let sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
56
60
  if (!sentences || sentences.length === 0) {
57
- let result = normalized
61
+ let result = normalized.replace(/__DECIMAL__/g, '.')
58
62
  placeholders.forEach(({ ph, original }) => {
59
63
  result = result.replace(ph, original)
60
64
  })
61
65
  return result.trim()
62
66
  }
63
67
 
64
- let result = sentences.slice(0, 2).join('')
68
+ // Never drop an unterminated leading fragment
69
+ const firstIdx = normalized.indexOf(sentences[0])
70
+ if (firstIdx > 0) {
71
+ sentences = [normalized.slice(0, firstIdx) + sentences[0], ...sentences.slice(1)]
72
+ }
73
+
74
+ let result = sentences.slice(0, 2).join('').replace(/__DECIMAL__/g, '.')
65
75
  placeholders.forEach(({ ph, original }) => {
66
76
  result = result.replace(ph, original)
67
77
  })
@@ -132,7 +132,14 @@ function compareFlags(oldFlag, newFlag) {
132
132
  * @returns {Object} Diff report
133
133
  */
134
134
  function generateRpkDiff(oldTree, newTree, options = {}) {
135
- const { oldVersion = 'old', newVersion = 'new' } = options
135
+ const {
136
+ oldVersion = 'old',
137
+ newVersion = 'new',
138
+ // deprecated_commands maps from the snapshots (path -> metadata), as
139
+ // produced by scan-deprecated-commands.js
140
+ oldDeprecatedCommands = {},
141
+ newDeprecatedCommands = {}
142
+ } = options
136
143
 
137
144
  const oldCommands = flattenToMap(oldTree)
138
145
  const newCommands = flattenToMap(newTree)
@@ -152,23 +159,95 @@ function generateRpkDiff(oldTree, newTree, options = {}) {
152
159
  }
153
160
  })
154
161
 
155
- // Find removed commands
162
+ // Newly deprecated commands: in the new snapshot's deprecation map but not
163
+ // the old one. Detected by source scanning, because deprecated commands
164
+ // that are also hidden never appear in --print-tree output.
165
+ const newlyDeprecatedRaw = Object.entries(newDeprecatedCommands)
166
+ .filter(([path]) => !oldDeprecatedCommands[path])
167
+ .map(([path, info]) => ({
168
+ path,
169
+ message: info.deprecatedMessage || '',
170
+ replacement: info.replacement || '',
171
+ hidden: info.hidden === true || /Hidden:\s*true/.test(info._note || ''),
172
+ deprecatedInVersion: newVersion
173
+ }))
174
+
175
+ // Roll deprecated subcommands up into their nearest deprecated ancestor:
176
+ // one entry for `rpk redpanda admin` with its subcommands listed reads
177
+ // better than a dozen sibling entries
178
+ const newlyDeprecatedDetails = newlyDeprecatedRaw.filter(d =>
179
+ !newlyDeprecatedRaw.some(other => other !== d && d.path.startsWith(other.path + ' ')))
180
+ for (const child of newlyDeprecatedRaw) {
181
+ if (newlyDeprecatedDetails.includes(child)) continue
182
+ const root = newlyDeprecatedDetails.find(r => child.path.startsWith(r.path + ' '))
183
+ if (root) {
184
+ root.affectedSubcommands = root.affectedSubcommands || []
185
+ if (!root.affectedSubcommands.includes(child.path)) {
186
+ root.affectedSubcommands.push(child.path)
187
+ }
188
+ }
189
+ }
190
+
191
+ // Find removed commands, then separate genuine removals from commands that
192
+ // disappeared from the tree because they (or an ancestor) were deprecated
193
+ // and hidden — those still work as aliases and should be reported as
194
+ // deprecations, not removals.
195
+ const deprecatedRoots = newlyDeprecatedDetails.map(d => d.path)
196
+ const isUnderNewlyDeprecated = (p) =>
197
+ deprecatedRoots.some(root => p === root || p.startsWith(root + ' '))
198
+
156
199
  const removedCommandPaths = [...oldPaths].filter(p => !newPaths.has(p))
157
- const removedCommandsDetails = removedCommandPaths.map(path => {
200
+ const removedCommandsDetails = []
201
+ for (const path of removedCommandPaths) {
202
+ if (isUnderNewlyDeprecated(path)) {
203
+ const root = deprecatedRoots.find(r => path === r || path.startsWith(r + ' '))
204
+ const entry = newlyDeprecatedDetails.find(d => d.path === root)
205
+ if (path !== root) {
206
+ entry.affectedSubcommands = entry.affectedSubcommands || []
207
+ if (!entry.affectedSubcommands.includes(path)) {
208
+ entry.affectedSubcommands.push(path)
209
+ }
210
+ }
211
+ continue
212
+ }
158
213
  const cmd = oldCommands.get(path)
159
- return {
214
+ removedCommandsDetails.push({
160
215
  path,
161
216
  name: cmd.name,
162
217
  description: cmd.description || '',
163
218
  removedInVersion: newVersion
164
- }
165
- })
219
+ })
220
+ }
221
+
222
+ for (const dep of newlyDeprecatedDetails) {
223
+ if (dep.affectedSubcommands) dep.affectedSubcommands.sort()
224
+ }
166
225
 
167
226
  // Find flag changes in existing commands
168
227
  const newFlags = []
169
228
  const removedFlags = []
170
229
  const changedDefaults = []
230
+ const changedFlagTypes = []
231
+ const changedFlagRequirements = []
232
+ const changedFlagDescriptions = []
171
233
  const descriptionChanges = []
234
+ const flagDataBackfilled = []
235
+
236
+ // Baseline gap detection: plugin subtrees captured before flag extraction
237
+ // existed record no flags on any of their own commands (the install/
238
+ // uninstall/upgrade shims are rpk-native and do carry flags). Flag
239
+ // "additions" inside such a group are newly captured documentation, not
240
+ // newly introduced flags, and stamping them "New in <version>" would
241
+ // mislabel long-standing flags. Core groups have baseline flag data, so a
242
+ // zero-flag command there that gains a flag is a genuine addition.
243
+ const groupsWithBaselineFlagData = new Set()
244
+ for (const [path, cmd] of oldCommands) {
245
+ const parts = path.split(' ')
246
+ if (parts.length < 2) continue
247
+ // "rpk <plugin> install|uninstall|upgrade" shims are rpk-native
248
+ if (parts.length === 3 && /^(install|uninstall|upgrade)$/.test(parts[2])) continue
249
+ if ((cmd.flags || []).length > 0) groupsWithBaselineFlagData.add(parts[1])
250
+ }
172
251
 
173
252
  for (const path of newPaths) {
174
253
  if (!oldPaths.has(path)) continue // Skip new commands
@@ -179,8 +258,16 @@ function generateRpkDiff(oldTree, newTree, options = {}) {
179
258
  const oldFlags = getFlagsMap(oldCmd)
180
259
  const newFlagsMap = getFlagsMap(newCmd)
181
260
 
182
- // Find new flags
183
- for (const [flagName, flag] of newFlagsMap) {
261
+ const topLevel = path.split(' ')[1]
262
+ const isFlagBackfill = topLevel !== undefined &&
263
+ !groupsWithBaselineFlagData.has(topLevel) &&
264
+ oldFlags.size === 0 && newFlagsMap.size > 0
265
+ if (isFlagBackfill) {
266
+ flagDataBackfilled.push({ commandPath: path, flagCount: newFlagsMap.size })
267
+ }
268
+
269
+ // Find new flags (skipped entirely for backfilled commands)
270
+ for (const [flagName, flag] of isFlagBackfill ? [] : newFlagsMap) {
184
271
  if (!oldFlags.has(flagName)) {
185
272
  newFlags.push({
186
273
  commandPath: path,
@@ -222,6 +309,30 @@ function generateRpkDiff(oldTree, newTree, options = {}) {
222
309
  newDefault: changes.default.new
223
310
  })
224
311
  }
312
+ if (changes.type) {
313
+ changedFlagTypes.push({
314
+ commandPath: path,
315
+ flagName,
316
+ oldType: changes.type.old,
317
+ newType: changes.type.new
318
+ })
319
+ }
320
+ if (changes.required) {
321
+ changedFlagRequirements.push({
322
+ commandPath: path,
323
+ flagName,
324
+ oldRequired: changes.required.old,
325
+ newRequired: changes.required.new
326
+ })
327
+ }
328
+ if (changes.description) {
329
+ changedFlagDescriptions.push({
330
+ commandPath: path,
331
+ flagName,
332
+ oldDescription: changes.description.old,
333
+ newDescription: changes.description.new
334
+ })
335
+ }
225
336
  }
226
337
  }
227
338
 
@@ -244,19 +355,29 @@ function generateRpkDiff(oldTree, newTree, options = {}) {
244
355
  },
245
356
  summary: {
246
357
  newCommands: newCommandsDetails.length,
358
+ newlyDeprecatedCommands: newlyDeprecatedDetails.length,
247
359
  removedCommands: removedCommandsDetails.length,
248
360
  newFlags: newFlags.length,
249
361
  removedFlags: removedFlags.length,
250
362
  changedDefaults: changedDefaults.length,
251
- descriptionChanges: descriptionChanges.length
363
+ changedFlagTypes: changedFlagTypes.length,
364
+ changedFlagRequirements: changedFlagRequirements.length,
365
+ changedFlagDescriptions: changedFlagDescriptions.length,
366
+ descriptionChanges: descriptionChanges.length,
367
+ flagDataBackfilled: flagDataBackfilled.length
252
368
  },
253
369
  details: {
254
370
  newCommands: newCommandsDetails,
371
+ newlyDeprecatedCommands: newlyDeprecatedDetails,
255
372
  removedCommands: removedCommandsDetails,
256
373
  newFlags,
257
374
  removedFlags,
258
375
  changedDefaults,
259
- descriptionChanges
376
+ changedFlagTypes,
377
+ changedFlagRequirements,
378
+ changedFlagDescriptions,
379
+ descriptionChanges,
380
+ flagDataBackfilled
260
381
  }
261
382
  }
262
383
  }
@@ -272,11 +393,18 @@ function printDiffReport(diff) {
272
393
 
273
394
  console.log('Summary:')
274
395
  console.log(` New commands: ${diff.summary.newCommands}`)
275
- console.log(` Deprecated commands: ${diff.summary.removedCommands}`)
396
+ console.log(` Deprecated commands: ${diff.summary.newlyDeprecatedCommands || 0}`)
397
+ console.log(` Removed commands: ${diff.summary.removedCommands}`)
276
398
  console.log(` New flags: ${diff.summary.newFlags}`)
277
- console.log(` Deprecated flags: ${diff.summary.removedFlags}`)
399
+ if (diff.summary.flagDataBackfilled) {
400
+ console.log(` Flag documentation backfilled: ${diff.summary.flagDataBackfilled} command(s) (baseline had no flag data; not reported as new flags)`)
401
+ }
402
+ console.log(` Removed flags: ${diff.summary.removedFlags}`)
278
403
  console.log(` Changed defaults: ${diff.summary.changedDefaults}`)
279
- console.log(` Description changes: ${diff.summary.descriptionChanges}`)
404
+ console.log(` Changed flag types: ${diff.summary.changedFlagTypes || 0}`)
405
+ console.log(` Changed flag requirements: ${diff.summary.changedFlagRequirements || 0}`)
406
+ console.log(` Changed flag descriptions: ${diff.summary.changedFlagDescriptions || 0}`)
407
+ console.log(` Command description changes: ${diff.summary.descriptionChanges}`)
280
408
 
281
409
  if (diff.details.newCommands.length > 0) {
282
410
  console.log('\nNew Commands:')
@@ -289,8 +417,19 @@ function printDiffReport(diff) {
289
417
  }
290
418
  }
291
419
 
420
+ if ((diff.details.newlyDeprecatedCommands || []).length > 0) {
421
+ console.log('\nDeprecated Commands (still work, hidden or discouraged):')
422
+ for (const cmd of diff.details.newlyDeprecatedCommands) {
423
+ console.log(` ⚠ ${cmd.path}${cmd.hidden ? ' (hidden)' : ''}`)
424
+ if (cmd.message) console.log(` ${cmd.message}`)
425
+ if ((cmd.affectedSubcommands || []).length > 0) {
426
+ console.log(` affects ${cmd.affectedSubcommands.length} subcommand(s)`)
427
+ }
428
+ }
429
+ }
430
+
292
431
  if (diff.details.removedCommands.length > 0) {
293
- console.log('\nDeprecated Commands (no longer in command tree):')
432
+ console.log('\nRemoved Commands (no longer in command tree):')
294
433
  for (const cmd of diff.details.removedCommands) {
295
434
  console.log(` ⚠ ${cmd.path}`)
296
435
  }
@@ -304,7 +443,7 @@ function printDiffReport(diff) {
304
443
  }
305
444
 
306
445
  if (diff.details.removedFlags.length > 0) {
307
- console.log('\nDeprecated Flags (no longer in command tree):')
446
+ console.log('\nRemoved Flags (no longer in command tree):')
308
447
  for (const flag of diff.details.removedFlags) {
309
448
  console.log(` ⚠ ${flag.commandPath} --${flag.flagName}`)
310
449
  }
@@ -318,6 +457,20 @@ function printDiffReport(diff) {
318
457
  }
319
458
  }
320
459
 
460
+ if ((diff.details.changedFlagTypes || []).length > 0) {
461
+ console.log('\nChanged Flag Types:')
462
+ for (const change of diff.details.changedFlagTypes) {
463
+ console.log(` ~ ${change.commandPath} --${change.flagName}: ${change.oldType} → ${change.newType}`)
464
+ }
465
+ }
466
+
467
+ if ((diff.details.changedFlagRequirements || []).length > 0) {
468
+ console.log('\nChanged Flag Requirements:')
469
+ for (const change of diff.details.changedFlagRequirements) {
470
+ console.log(` ~ ${change.commandPath} --${change.flagName}: required ${change.oldRequired} → ${change.newRequired}`)
471
+ }
472
+ }
473
+
321
474
  console.log('')
322
475
  }
323
476
 
@@ -339,10 +492,13 @@ function generateMarkdownSummary(diff) {
339
492
  lines.push(`| Category | Count |`)
340
493
  lines.push(`|----------|-------|`)
341
494
  lines.push(`| New commands | ${diff.summary.newCommands} |`)
342
- lines.push(`| Deprecated commands | ${diff.summary.removedCommands} |`)
495
+ lines.push(`| Removed commands | ${diff.summary.removedCommands} |`)
343
496
  lines.push(`| New flags | ${diff.summary.newFlags} |`)
344
- lines.push(`| Deprecated flags | ${diff.summary.removedFlags} |`)
497
+ lines.push(`| Removed flags | ${diff.summary.removedFlags} |`)
345
498
  lines.push(`| Changed defaults | ${diff.summary.changedDefaults} |`)
499
+ if (diff.summary.flagDataBackfilled) {
500
+ lines.push(`| Flag docs backfilled (baseline had no flag data) | ${diff.summary.flagDataBackfilled} commands |`)
501
+ }
346
502
  lines.push(``)
347
503
 
348
504
  if (diff.details.newCommands.length > 0) {
@@ -355,9 +511,9 @@ function generateMarkdownSummary(diff) {
355
511
  }
356
512
 
357
513
  if (diff.details.removedCommands.length > 0) {
358
- lines.push(`### Deprecated Commands`)
514
+ lines.push(`### Removed Commands`)
359
515
  lines.push(``)
360
- lines.push(`> Commands no longer in the active command tree. These may still work but are deprecated.`)
516
+ lines.push(`> Commands no longer in the active command tree.`)
361
517
  lines.push(``)
362
518
  for (const cmd of diff.details.removedCommands) {
363
519
  lines.push(`- ~~\`${cmd.path}\`~~`)
@@ -411,32 +567,67 @@ function commandPathToXref(commandPath) {
411
567
  function generateWhatsNewSection(diff, options = {}) {
412
568
  const lines = []
413
569
  const version = options.version || diff.comparison.newVersion
570
+ // Plugin subtrees may render as partials with no linkable pages, so plugin
571
+ // runs disable xrefs and render plain command names instead.
572
+ const useXrefs = options.xrefs !== false
573
+ // Commands that render as partials or are excluded have no linkable page
574
+ const linkable = typeof options.linkable === 'function' ? options.linkable : () => true
575
+ // Section heading for the page ("== Redpanda CLI" for core rpk changes,
576
+ // "== rpk plugins" for plugin releases). When blockLabel is set, the block
577
+ // opens with a "=== <label>" heading and category headings nest one level
578
+ // deeper, so accumulated blocks never produce colliding section ids.
579
+ const sectionHeading = options.sectionHeading || '== Redpanda CLI'
580
+ const blockLabel = options.blockLabel || null
581
+ const h = blockLabel ? '====' : '==='
582
+ // Command groups (two-part commands with subcommands) render into their own
583
+ // directory: rpk check -> rpk-check/rpk-check.adoc, not rpk-check.adoc
584
+ const hasSubcommands = typeof options.hasSubcommands === 'function'
585
+ ? options.hasSubcommands
586
+ : () => false
587
+ const cmdRef = (commandPath) => {
588
+ if (!useXrefs || !linkable(commandPath)) return `\`${commandPath}\``
589
+ let xrefPath = commandPathToXref(commandPath)
590
+ if (commandPath.split(' ').length === 2 && hasSubcommands(commandPath)) {
591
+ const dashified = commandPath.replace(/ /g, '-')
592
+ xrefPath = `${dashified}/${dashified}.adoc`
593
+ }
594
+ return `xref:reference:rpk/${xrefPath}[\`${commandPath}\`]`
595
+ }
414
596
 
415
597
  // Check if there are any changes worth documenting
416
598
  const hasNewCommands = diff.details.newCommands.length > 0
599
+ const hasDeprecatedCommands = (diff.details.newlyDeprecatedCommands || []).length > 0
600
+ const hasRemovedCommands = diff.details.removedCommands.length > 0
417
601
  const hasNewFlags = diff.details.newFlags.length > 0
602
+ const hasRemovedFlags = diff.details.removedFlags.length > 0
418
603
  const hasChangedDefaults = diff.details.changedDefaults.length > 0
604
+ const hasChangedFlagTypes = (diff.details.changedFlagTypes || []).length > 0
419
605
 
420
- if (!hasNewCommands && !hasNewFlags && !hasChangedDefaults) {
606
+ if (!hasNewCommands && !hasDeprecatedCommands && !hasRemovedCommands &&
607
+ !hasNewFlags && !hasRemovedFlags && !hasChangedDefaults && !hasChangedFlagTypes) {
421
608
  return '' // No changes to document
422
609
  }
423
610
 
424
- lines.push(`== Redpanda CLI`)
611
+ lines.push(sectionHeading)
425
612
  lines.push(``)
613
+ if (blockLabel) {
614
+ lines.push(`=== ${blockLabel}`)
615
+ lines.push(``)
616
+ }
426
617
 
427
618
  if (hasNewCommands) {
428
- lines.push(`=== New commands`)
619
+ lines.push(`${h} New commands`)
429
620
  lines.push(``)
430
621
  for (const cmd of diff.details.newCommands) {
431
- const xrefPath = commandPathToXref(cmd.path)
432
- const desc = cmd.description ? ` - ${cmd.description}` : ''
433
- lines.push(`* xref:reference:rpk/${xrefPath}[\`${cmd.path}\`]${desc}`)
622
+ const shortDesc = firstSentence(cmd.description || '')
623
+ const desc = shortDesc ? ` - ${shortDesc}` : ''
624
+ lines.push(`* ${cmdRef(cmd.path)}${desc}`)
434
625
  }
435
626
  lines.push(``)
436
627
  }
437
628
 
438
629
  if (hasNewFlags) {
439
- lines.push(`=== New flags`)
630
+ lines.push(`${h} New flags`)
440
631
  lines.push(``)
441
632
  // Group flags by command
442
633
  const flagsByCommand = {}
@@ -448,19 +639,73 @@ function generateWhatsNewSection(diff, options = {}) {
448
639
  }
449
640
 
450
641
  for (const [cmdPath, flags] of Object.entries(flagsByCommand)) {
451
- const xrefPath = commandPathToXref(cmdPath)
452
642
  const flagList = flags.map(f => `\`--${f.flagName}\``).join(', ')
453
- lines.push(`* xref:reference:rpk/${xrefPath}[\`${cmdPath}\`]: Added ${flagList}`)
643
+ lines.push(`* ${cmdRef(cmdPath)}: Added ${flagList}`)
454
644
  }
455
645
  lines.push(``)
456
646
  }
457
647
 
458
648
  if (hasChangedDefaults) {
459
- lines.push(`=== Changed defaults`)
649
+ lines.push(`${h} Changed defaults`)
460
650
  lines.push(``)
461
651
  for (const change of diff.details.changedDefaults) {
462
- const xrefPath = commandPathToXref(change.commandPath)
463
- lines.push(`* xref:reference:rpk/${xrefPath}[\`${change.commandPath}\`]: \`--${change.flagName}\` default changed from \`${change.oldDefault}\` to \`${change.newDefault}\``)
652
+ const oldVal = formatFlagValue(change.oldDefault)
653
+ const newVal = formatFlagValue(change.newDefault)
654
+ lines.push(`* ${cmdRef(change.commandPath)}: \`--${change.flagName}\` default changed from \`${oldVal}\` to \`${newVal}\``)
655
+ }
656
+ lines.push(``)
657
+ }
658
+
659
+ if (hasChangedFlagTypes) {
660
+ lines.push(`${h} Changed flag types`)
661
+ lines.push(``)
662
+ for (const change of diff.details.changedFlagTypes) {
663
+ lines.push(`* ${cmdRef(change.commandPath)}: \`--${change.flagName}\` type changed from \`${change.oldType}\` to \`${change.newType}\``)
664
+ }
665
+ lines.push(``)
666
+ }
667
+
668
+ if (hasDeprecatedCommands) {
669
+ lines.push(`${h} Deprecated commands`)
670
+ lines.push(``)
671
+ for (const cmd of diff.details.newlyDeprecatedCommands) {
672
+ let entry = `* \`${cmd.path}\``
673
+ if (cmd.replacement) {
674
+ entry += `: ${cmd.replacement}`
675
+ } else if (cmd.message) {
676
+ entry += `: ${cmd.message}`
677
+ }
678
+ lines.push(entry)
679
+ if ((cmd.affectedSubcommands || []).length > 0) {
680
+ lines.push(`+`)
681
+ lines.push(`Includes its subcommands: ${cmd.affectedSubcommands.map(s => `\`${s}\``).join(', ')}.`)
682
+ }
683
+ }
684
+ lines.push(``)
685
+ }
686
+
687
+ if (hasRemovedCommands) {
688
+ lines.push(`${h} Removed commands`)
689
+ lines.push(``)
690
+ for (const cmd of diff.details.removedCommands) {
691
+ lines.push(`* \`${cmd.path}\``)
692
+ }
693
+ lines.push(``)
694
+ }
695
+
696
+ if (hasRemovedFlags) {
697
+ lines.push(`${h} Removed flags`)
698
+ lines.push(``)
699
+ const removedByCommand = {}
700
+ for (const flag of diff.details.removedFlags) {
701
+ if (!removedByCommand[flag.commandPath]) {
702
+ removedByCommand[flag.commandPath] = []
703
+ }
704
+ removedByCommand[flag.commandPath].push(flag)
705
+ }
706
+ for (const [cmdPath, flags] of Object.entries(removedByCommand)) {
707
+ const flagList = flags.map(f => `\`--${f.flagName}\``).join(', ')
708
+ lines.push(`* ${cmdRef(cmdPath)}: Removed ${flagList}`)
464
709
  }
465
710
  lines.push(``)
466
711
  }
@@ -468,6 +713,41 @@ function generateWhatsNewSection(diff, options = {}) {
468
713
  return lines.join('\n')
469
714
  }
470
715
 
716
+ /**
717
+ * Extract the first sentence of a description for a one-line bullet.
718
+ * Newlines are collapsed first (cobra help wraps mid-sentence), and decimal
719
+ * points in version numbers do not end a sentence. Falls back to the whole
720
+ * collapsed text when no sentence boundary exists.
721
+ * @param {string} str - Command description
722
+ * @returns {string}
723
+ */
724
+ function firstSentence(str) {
725
+ if (!str) return ''
726
+ // A blank line separates the summary from the long text even when the
727
+ // summary has no terminal period ("Install Redpanda Check\n\nThis
728
+ // command..."), so cut at the paragraph break before joining the
729
+ // hard-wrapped lines within it.
730
+ const firstParagraph = String(str).split(/\n\s*\n/, 1)[0]
731
+ const singleLine = firstParagraph.replace(/\s*\n+\s*/g, ' ').trim()
732
+ const protectedText = singleLine.replace(/(\d)\.(\d)/g, '$1__DECIMAL__$2')
733
+ const match = protectedText.match(/^.*?[.!?](?=\s|$)/)
734
+ const sentence = match ? match[0] : protectedText
735
+ return sentence.replace(/__DECIMAL__/g, '.').trim()
736
+ }
737
+
738
+ /**
739
+ * Render a flag value for display. Objects and arrays are JSON-encoded so
740
+ * they never render as [object Object].
741
+ * @param {*} value - Flag default value
742
+ * @returns {string}
743
+ */
744
+ function formatFlagValue(value) {
745
+ if (value === undefined) return 'unset'
746
+ if (value === null) return 'null'
747
+ if (typeof value === 'object') return JSON.stringify(value)
748
+ return String(value)
749
+ }
750
+
471
751
  module.exports = {
472
752
  generateRpkDiff,
473
753
  printDiffReport,
@@ -475,5 +755,7 @@ module.exports = {
475
755
  generateWhatsNewSection,
476
756
  flattenToMap,
477
757
  getFlagsMap,
478
- compareFlags
758
+ compareFlags,
759
+ // Exported for testing
760
+ firstSentence
479
761
  }