@redpanda-data/docs-extensions-and-macros 5.0.3 → 5.1.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.
@@ -0,0 +1,2433 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const handlebars = require('handlebars')
6
+ const helpers = require('./helpers')
7
+
8
+ // Register Handlebars helpers
9
+ Object.entries(helpers).forEach(([name, fn]) => {
10
+ if (typeof fn === 'function') {
11
+ handlebars.registerHelper(name, fn)
12
+ }
13
+ })
14
+
15
+ // Template paths
16
+ const TEMPLATES_DIR = path.resolve(__dirname, './templates')
17
+
18
+ /**
19
+ * Register a Handlebars partial from file
20
+ * @param {string} name - Partial name
21
+ * @param {string} filePath - Path to template file
22
+ */
23
+ function registerPartial(name, filePath) {
24
+ const resolved = path.resolve(filePath)
25
+ try {
26
+ const source = fs.readFileSync(resolved, 'utf8')
27
+ handlebars.registerPartial(name, source)
28
+ } catch (err) {
29
+ console.warn(`Warning: Could not load partial "${name}" from ${resolved}`)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Load and compile a Handlebars template
35
+ * @param {string} templatePath - Path to template file
36
+ * @returns {Function} Compiled template function
37
+ */
38
+ function loadTemplate(templatePath) {
39
+ const source = fs.readFileSync(templatePath, 'utf8')
40
+ return handlebars.compile(source)
41
+ }
42
+
43
+ /**
44
+ * Resolve $ref references in overrides object with cycle detection
45
+ * @param {Object} obj - Object to resolve
46
+ * @param {Object} root - Root object containing definitions
47
+ * @param {string} [context] - Context for error messages (e.g., command path)
48
+ * @param {Set} [visited] - Set of visited refs for cycle detection
49
+ * @param {number} [depth] - Current recursion depth
50
+ * @returns {Object} Resolved object
51
+ */
52
+ function resolveReferences(obj, root, context = '', visited = new Set(), depth = 0) {
53
+ // Prevent infinite recursion
54
+ const MAX_DEPTH = 50
55
+ if (depth > MAX_DEPTH) {
56
+ console.error(`ERROR: Maximum reference resolution depth exceeded (${MAX_DEPTH})`)
57
+ console.error(` Context: ${context || 'root'}`)
58
+ console.error(` This may indicate circular references in your overrides.`)
59
+ return obj
60
+ }
61
+
62
+ if (!obj || typeof obj !== 'object') return obj
63
+
64
+ if (Array.isArray(obj)) {
65
+ return obj.map((item, i) =>
66
+ resolveReferences(item, root, `${context}[${i}]`, visited, depth + 1)
67
+ )
68
+ }
69
+
70
+ // Handle $ref
71
+ if (obj.$ref && typeof obj.$ref === 'string') {
72
+ const ref = obj.$ref
73
+
74
+ // Check for cycles
75
+ if (visited.has(ref)) {
76
+ console.error(`ERROR: Circular reference detected: ${ref}`)
77
+ console.error(` Context: ${context || 'root'}`)
78
+ console.error(` Reference chain would create infinite loop.`)
79
+ const { $ref, ...rest } = obj
80
+ return rest // Return without the reference to prevent infinite loop
81
+ }
82
+
83
+ // Validate ref format
84
+ if (!ref.startsWith('#/')) {
85
+ console.error(`ERROR: Invalid $ref format: "${ref}"`)
86
+ console.error(` Context: ${context || 'root'}`)
87
+ console.error(` References must start with #/ (e.g., #/definitions/my-flags)`)
88
+ return obj
89
+ }
90
+
91
+ const refPath = ref.replace(/^#\//, '').split('/')
92
+ let resolved = root
93
+ for (const part of refPath) {
94
+ if (resolved && typeof resolved === 'object') {
95
+ resolved = resolved[part]
96
+ } else {
97
+ resolved = undefined
98
+ break
99
+ }
100
+ }
101
+
102
+ if (resolved !== undefined) {
103
+ const newVisited = new Set(visited)
104
+ newVisited.add(ref)
105
+ const { $ref: _, ...rest } = obj
106
+ return {
107
+ ...resolveReferences(resolved, root, `${context}.$ref(${ref})`, newVisited, depth + 1),
108
+ ...rest
109
+ }
110
+ }
111
+
112
+ // Reference not found - provide helpful error
113
+ console.error(`ERROR: Cannot resolve $ref: "${ref}"`)
114
+ console.error(` Context: ${context || 'root'}`)
115
+
116
+ // Suggest similar paths
117
+ const findPaths = (obj, prefix = '#') => {
118
+ const paths = []
119
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
120
+ for (const key of Object.keys(obj)) {
121
+ const path = `${prefix}/${key}`
122
+ paths.push(path)
123
+ paths.push(...findPaths(obj[key], path))
124
+ }
125
+ }
126
+ return paths
127
+ }
128
+ const availablePaths = findPaths(root)
129
+ const refBase = ref.split('/').slice(0, -1).join('/')
130
+ const similar = availablePaths
131
+ .filter(p => p.startsWith(refBase) || p.includes(refPath[refPath.length - 1]))
132
+ .slice(0, 3)
133
+
134
+ if (similar.length > 0) {
135
+ console.error(` Did you mean: ${similar.join(', ')}?`)
136
+ }
137
+
138
+ // Return object without the unresolved $ref
139
+ const { $ref: _, ...rest } = obj
140
+ return rest
141
+ }
142
+
143
+ // Handle $refs array (merge multiple references)
144
+ // Order: $refs provide defaults, explicit properties override
145
+ if (obj.$refs && Array.isArray(obj.$refs)) {
146
+ const { $refs, ...rest } = obj
147
+ let merged = {}
148
+
149
+ // First, merge all $refs in order (later $refs override earlier ones)
150
+ for (let i = 0; i < $refs.length; i++) {
151
+ const ref = $refs[i]
152
+
153
+ // Check for cycles
154
+ if (visited.has(ref)) {
155
+ console.error(`ERROR: Circular reference detected in $refs: ${ref}`)
156
+ console.error(` Context: ${context || 'root'}.$refs[${i}]`)
157
+ continue
158
+ }
159
+
160
+ if (!ref.startsWith('#/')) {
161
+ console.error(`ERROR: Invalid $refs entry: "${ref}" (must start with #/)`)
162
+ console.error(` Context: ${context || 'root'}.$refs[${i}]`)
163
+ continue
164
+ }
165
+
166
+ const refPath = ref.replace(/^#\//, '').split('/')
167
+ let resolved = root
168
+ for (const part of refPath) {
169
+ if (resolved && typeof resolved === 'object') {
170
+ resolved = resolved[part]
171
+ } else {
172
+ resolved = undefined
173
+ break
174
+ }
175
+ }
176
+
177
+ if (resolved !== undefined) {
178
+ const newVisited = new Set(visited)
179
+ newVisited.add(ref)
180
+ merged = deepMerge(
181
+ merged,
182
+ resolveReferences(resolved, root, `${context}.$refs[${i}](${ref})`, newVisited, depth + 1)
183
+ )
184
+ } else {
185
+ console.error(`ERROR: Cannot resolve $refs entry: "${ref}"`)
186
+ console.error(` Context: ${context || 'root'}.$refs[${i}]`)
187
+ }
188
+ }
189
+
190
+ // Then overlay explicit properties (they take precedence over $refs)
191
+ return deepMerge(merged, rest)
192
+ }
193
+
194
+ // Recursively resolve nested objects
195
+ const result = {}
196
+ for (const [key, value] of Object.entries(obj)) {
197
+ result[key] = resolveReferences(value, root, `${context}.${key}`, visited, depth + 1)
198
+ }
199
+ return result
200
+ }
201
+
202
+ /**
203
+ * Deep merge two objects with array deduplication
204
+ * @param {Object} target - Target object
205
+ * @param {Object} source - Source object
206
+ * @returns {Object} Merged object
207
+ */
208
+ function deepMerge(target, source) {
209
+ if (!source || typeof source !== 'object') return target
210
+ if (!target || typeof target !== 'object') return source
211
+
212
+ const result = { ...target }
213
+
214
+ for (const [key, value] of Object.entries(source)) {
215
+ if (Array.isArray(value) && Array.isArray(result[key])) {
216
+ const targetArray = result[key]
217
+
218
+ // Check if this is a string-only array (prerequisites, seeAlso, etc.)
219
+ const isStringArray = targetArray.every(item => typeof item === 'string') &&
220
+ value.every(item => typeof item === 'string')
221
+
222
+ if (isStringArray) {
223
+ // Deduplicate string arrays
224
+ const combined = [...targetArray, ...value]
225
+ result[key] = [...new Set(combined)]
226
+ } else {
227
+ // Merge arrays by name if items have name property
228
+ const merged = [...targetArray]
229
+
230
+ for (const sourceItem of value) {
231
+ if (sourceItem && typeof sourceItem === 'object' && sourceItem.name) {
232
+ const targetIdx = merged.findIndex(t => t && t.name === sourceItem.name)
233
+ if (targetIdx >= 0) {
234
+ merged[targetIdx] = deepMerge(merged[targetIdx], sourceItem)
235
+ } else {
236
+ merged.push(sourceItem)
237
+ }
238
+ } else if (sourceItem && typeof sourceItem === 'object') {
239
+ // Object without name - check for duplicates by deep equality
240
+ const isDuplicate = merged.some(t =>
241
+ JSON.stringify(t) === JSON.stringify(sourceItem)
242
+ )
243
+ if (!isDuplicate) {
244
+ merged.push(sourceItem)
245
+ }
246
+ } else {
247
+ // Primitive - add if not already present
248
+ if (!merged.includes(sourceItem)) {
249
+ merged.push(sourceItem)
250
+ }
251
+ }
252
+ }
253
+ result[key] = merged
254
+ }
255
+ } else if (typeof value === 'object' && typeof result[key] === 'object' && !Array.isArray(value)) {
256
+ result[key] = deepMerge(result[key], value)
257
+ } else {
258
+ result[key] = value
259
+ }
260
+ }
261
+
262
+ return result
263
+ }
264
+
265
+ /**
266
+ * Check if a command should be excluded from documentation
267
+ * @param {Object} overrides - Overrides object (resolved)
268
+ * @param {string} commandPath - Full command path (e.g., "rpk topic create")
269
+ * @returns {boolean} True if command should be excluded
270
+ */
271
+ function shouldExcludeCommand(overrides, commandPath) {
272
+ if (!overrides || !overrides.commands) return false
273
+ const commandOverride = overrides.commands[commandPath]
274
+ return commandOverride?.exclude === true
275
+ }
276
+
277
+ /**
278
+ * Get command metadata from overrides
279
+ * @param {Object} overrides - Overrides object (resolved)
280
+ * @param {string} commandPath - Full command path (e.g., "rpk topic create")
281
+ * @returns {Object} Command metadata
282
+ */
283
+ function getCommandMetadata(overrides, commandPath) {
284
+ if (!overrides || !overrides.commands) return {}
285
+ return overrides.commands[commandPath] || {}
286
+ }
287
+
288
+ /**
289
+ * Valid content positions
290
+ */
291
+ const VALID_CONTENT_POSITIONS = new Set([
292
+ 'after_header',
293
+ 'after_description',
294
+ 'after_usage',
295
+ 'after_aliases',
296
+ 'after_flags',
297
+ 'after_modifiers',
298
+ 'after_examples',
299
+ 'before_see_also',
300
+ 'end'
301
+ ])
302
+
303
+ /**
304
+ * Check if admonition content needs block format (complex) vs simple format
305
+ * @param {string} content - Admonition content
306
+ * @returns {boolean} True if complex format needed
307
+ */
308
+ function isComplexAdmonition(content) {
309
+ if (!content) return false
310
+ // Check for includes, multiple paragraphs, code blocks, tables
311
+ return content.includes('\n\n') ||
312
+ content.includes('include::') ||
313
+ content.includes('[,') ||
314
+ content.includes('----') ||
315
+ content.includes('|===')
316
+ }
317
+
318
+ /**
319
+ * Wrap admonition content in appropriate format
320
+ * @param {string} type - Admonition type (note, warning, etc.)
321
+ * @param {string} content - Content to wrap
322
+ * @returns {string} Wrapped admonition
323
+ */
324
+ function wrapAdmonition(type, content) {
325
+ const upperType = type.toUpperCase()
326
+
327
+ // Check if already manually wrapped (backward compatibility)
328
+ if (content.trim().startsWith(`[${upperType}]`)) {
329
+ return content
330
+ }
331
+
332
+ // Detect complexity and wrap accordingly
333
+ if (isComplexAdmonition(content)) {
334
+ // Block format for complex content
335
+ return `[${upperType}]\n====\n${content}\n====`
336
+ } else {
337
+ // Simple format for single paragraph
338
+ return `${upperType}: ${content}`
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Process unified content array into position-grouped content
344
+ * @param {Array} contentArray - Array of content items from overrides
345
+ * @param {string} [context] - Context for error messages (e.g., command path)
346
+ * @returns {Object} Content grouped by position, with rendered AsciiDoc
347
+ */
348
+ function processContentArray(contentArray, context = '') {
349
+ if (!contentArray || !Array.isArray(contentArray)) {
350
+ return {
351
+ sections: {},
352
+ admonitions: {},
353
+ cloudContent: {},
354
+ selfHostedContent: {},
355
+ includes: {}
356
+ }
357
+ }
358
+
359
+ const result = {
360
+ sections: {}, // position -> array of {id, title, content}
361
+ admonitions: {}, // position -> rendered admonition string
362
+ cloudContent: {}, // position -> content string
363
+ selfHostedContent: {}, // position -> content string
364
+ includes: {} // position -> array of paths
365
+ }
366
+
367
+ // Initialize arrays/objects for each position
368
+ for (const pos of VALID_CONTENT_POSITIONS) {
369
+ result.sections[pos] = []
370
+ result.admonitions[pos] = []
371
+ result.cloudContent[pos] = []
372
+ result.selfHostedContent[pos] = []
373
+ result.includes[pos] = []
374
+ }
375
+
376
+ for (const item of contentArray) {
377
+ const { type, position, content, id, title, path, paths, exclude } = item
378
+
379
+ // Skip excluded items
380
+ if (exclude === true) {
381
+ continue
382
+ }
383
+
384
+ // Validate position
385
+ if (!VALID_CONTENT_POSITIONS.has(position)) {
386
+ console.warn(`WARNING: Invalid content position "${position}" for type "${type}"`)
387
+ if (context) console.warn(` Context: ${context}`)
388
+ console.warn(` Valid positions: ${[...VALID_CONTENT_POSITIONS].join(', ')}`)
389
+ continue
390
+ }
391
+
392
+ switch (type) {
393
+ case 'section':
394
+ // Pass through all section properties (subsections, parent, headingLevel, exclude)
395
+ result.sections[position].push({
396
+ type: 'section',
397
+ id,
398
+ title,
399
+ content,
400
+ subsections: item.subsections,
401
+ parent: item.parent,
402
+ headingLevel: item.headingLevel,
403
+ exclude
404
+ })
405
+ // Deprecation warning for manual "See also" sections
406
+ if (content && content.includes('== See also')) {
407
+ console.warn(`⚠️ Deprecated: Manual "See also" section in ${context || 'unknown'}`)
408
+ console.warn(` Use seeAlso key instead`)
409
+ }
410
+ break
411
+
412
+ case 'example':
413
+ // Structured single example
414
+ result.sections[position].push({
415
+ type: 'example',
416
+ description: item.description,
417
+ code: item.code,
418
+ language: item.language || 'bash',
419
+ attributes: item.attributes
420
+ })
421
+ break
422
+
423
+ case 'examples':
424
+ // Structured multiple examples
425
+ result.sections[position].push({
426
+ type: 'examples',
427
+ title: item.title || 'Examples',
428
+ items: item.items
429
+ })
430
+ break
431
+
432
+ case 'note':
433
+ // Use wrapAdmonition for smart formatting
434
+ result.admonitions[position].push(wrapAdmonition('note', content))
435
+ break
436
+
437
+ case 'warning':
438
+ result.admonitions[position].push(wrapAdmonition('warning', content))
439
+ break
440
+
441
+ case 'tip':
442
+ result.admonitions[position].push(wrapAdmonition('tip', content))
443
+ break
444
+
445
+ case 'caution':
446
+ result.admonitions[position].push(wrapAdmonition('caution', content))
447
+ break
448
+
449
+ case 'important':
450
+ result.admonitions[position].push(wrapAdmonition('important', content))
451
+ break
452
+
453
+ case 'cloud-only':
454
+ result.cloudContent[position].push(content)
455
+ break
456
+
457
+ case 'self-hosted':
458
+ result.selfHostedContent[position].push(content)
459
+ break
460
+
461
+ case 'include':
462
+ if (path) {
463
+ result.includes[position].push(path)
464
+ }
465
+ if (paths && Array.isArray(paths)) {
466
+ result.includes[position].push(...paths)
467
+ }
468
+ break
469
+
470
+ default:
471
+ console.warn(`WARNING: Unknown content type "${type}"`)
472
+ if (context) console.warn(` Context: ${context}`)
473
+ }
474
+ }
475
+
476
+ // Convert arrays to rendered strings where appropriate
477
+ const rendered = {
478
+ sections: result.sections,
479
+ admonitions: {},
480
+ cloudContent: {},
481
+ selfHostedContent: {},
482
+ includes: {}
483
+ }
484
+
485
+ for (const pos of VALID_CONTENT_POSITIONS) {
486
+ // Join admonitions with double newlines
487
+ rendered.admonitions[pos] = result.admonitions[pos].join('\n\n')
488
+
489
+ // Join cloud content
490
+ rendered.cloudContent[pos] = result.cloudContent[pos].join('\n\n')
491
+
492
+ // Join self-hosted content
493
+ rendered.selfHostedContent[pos] = result.selfHostedContent[pos].join('\n\n')
494
+
495
+ // Keep includes as array
496
+ rendered.includes[pos] = result.includes[pos]
497
+ }
498
+
499
+ return rendered
500
+ }
501
+
502
+ /**
503
+ * Merge overrides into command data
504
+ * @param {Object} command - Command object
505
+ * @param {Object} overrides - Overrides object (resolved)
506
+ * @param {string} commandPath - Full command path (e.g., "rpk topic create")
507
+ * @returns {Object} Merged command
508
+ */
509
+ function mergeCommandOverrides(command, overrides, commandPath) {
510
+ if (!overrides || !overrides.commands) return command
511
+
512
+ const commandOverride = overrides.commands[commandPath]
513
+ if (!commandOverride) return command
514
+
515
+ const result = { ...command }
516
+
517
+ // Override description while preserving sections from original
518
+ if (commandOverride.description) {
519
+
520
+ // Parse sections from original description
521
+ const originalParsed = parseDescriptionSections(command.description || '')
522
+ // Parse sections from override description (in case override includes sections)
523
+ const overrideParsed = parseDescriptionSections(commandOverride.description)
524
+
525
+ // If override only has main description (no sections), preserve original sections
526
+ // UNLESS the override has content items (which replace the sections)
527
+ const hasOverrideSections = Object.keys(overrideParsed.sections).length > 0
528
+ const hasContentItems = commandOverride.content && commandOverride.content.length > 0
529
+ if (!hasOverrideSections && !hasContentItems && Object.keys(originalParsed.sections).length > 0) {
530
+ // Rebuild description with override main text + original sections
531
+ let rebuiltDescription = overrideParsed.mainDescription
532
+ for (const [sectionName, sectionContent] of Object.entries(originalParsed.sections)) {
533
+ rebuiltDescription += `\n\n${sectionName}\n\n${sectionContent}`
534
+ }
535
+ result.description = rebuiltDescription
536
+ } else {
537
+ // Override has sections, content items, or original has none - use override as-is
538
+ result.description = commandOverride.description
539
+ }
540
+
541
+ }
542
+
543
+ // Append to description if specified
544
+ if (commandOverride.appendToDescription) {
545
+ result.description = (result.description || '') + '\n\n' + commandOverride.appendToDescription
546
+ }
547
+
548
+ // Copy description scope (for conditional rendering)
549
+ if (commandOverride.descriptionScope) {
550
+ result.descriptionScope = commandOverride.descriptionScope
551
+ }
552
+
553
+ // Override flags
554
+ if (result.flags) {
555
+ // First, filter out excluded flags
556
+ if (commandOverride.excludeFlags) {
557
+ const excludeSet = new Set(commandOverride.excludeFlags)
558
+ result.flags = result.flags.filter(flag => !excludeSet.has(flag.name))
559
+ }
560
+
561
+ // Then apply flag overrides
562
+ if (commandOverride.flags) {
563
+ result.flags = result.flags.map(flag => {
564
+ const flagOverride = commandOverride.flags[flag.name]
565
+ if (flagOverride) {
566
+ return { ...flag, ...flagOverride }
567
+ }
568
+ return flag
569
+ })
570
+ }
571
+ }
572
+
573
+ // Add introduced version if specified
574
+ if (commandOverride.introducedInVersion) {
575
+ result.introducedInVersion = commandOverride.introducedInVersion
576
+ }
577
+
578
+ // Copy deprecation info
579
+ if (commandOverride.deprecated) {
580
+ result.deprecated = true
581
+ result.deprecatedMessage = commandOverride.deprecatedMessage
582
+ result.deprecatedInVersion = commandOverride.deprecatedInVersion
583
+ result.removedInVersion = commandOverride.removedInVersion
584
+ result.replacement = commandOverride.replacement
585
+ }
586
+
587
+ // Copy minVersion
588
+ if (commandOverride.minVersion) {
589
+ result.minVersion = commandOverride.minVersion
590
+ }
591
+
592
+ // Copy platforms from override (explicit override takes precedence)
593
+ if (commandOverride.platforms) {
594
+ result.platforms = commandOverride.platforms
595
+ }
596
+
597
+ // Copy prerequisites
598
+ if (commandOverride.prerequisites) {
599
+ result.prerequisites = commandOverride.prerequisites
600
+ }
601
+
602
+ // Copy seeAlso
603
+ if (commandOverride.seeAlso) {
604
+ result.seeAlso = commandOverride.seeAlso
605
+ }
606
+
607
+ // Copy pageAliases
608
+ if (commandOverride.pageAliases) {
609
+ result.pageAliases = commandOverride.pageAliases
610
+ }
611
+
612
+ // Copy aliases override
613
+ if (commandOverride.aliases) {
614
+ result.aliases = commandOverride.aliases
615
+ }
616
+
617
+ // Merge unified content array
618
+ if (command.content || commandOverride.content) {
619
+ const baseContent = command.content || []
620
+ const overrideContent = commandOverride.content || []
621
+
622
+ // Build a map of override items by id for quick lookup
623
+ const overrideById = new Map()
624
+ for (const item of overrideContent) {
625
+ if (item.id) {
626
+ overrideById.set(item.id, item)
627
+ }
628
+ }
629
+
630
+ // Merge: start with base content, applying overrides by id
631
+ const mergedContent = []
632
+ for (const item of baseContent) {
633
+ if (item.id && overrideById.has(item.id)) {
634
+ const override = overrideById.get(item.id)
635
+ // If override has exclude: true, skip this item entirely
636
+ if (override.exclude === true) {
637
+ continue
638
+ }
639
+ // Otherwise, merge the override with the base item
640
+ mergedContent.push({ ...item, ...override })
641
+ } else {
642
+ mergedContent.push(item)
643
+ }
644
+ }
645
+
646
+ // Add any override items that don't have an id match in base content
647
+ for (const item of overrideContent) {
648
+ if (!item.id || !baseContent.some(baseItem => baseItem.id === item.id)) {
649
+ // Include all items, even those with exclude: true
650
+ // (exclude directives may be used to remove sections from description)
651
+ mergedContent.push(item)
652
+ }
653
+ }
654
+
655
+ result.content = mergedContent
656
+ }
657
+
658
+ // Copy cloud/self-hosted only flags
659
+ if (commandOverride.cloudOnly) {
660
+ result.cloudOnly = true
661
+ }
662
+ if (commandOverride.selfHostedOnly) {
663
+ result.selfHostedOnly = true
664
+ }
665
+
666
+ // Copy excludeExamples
667
+ if (commandOverride.excludeExamples) {
668
+ result.excludeExamples = commandOverride.excludeExamples
669
+ }
670
+
671
+ return result
672
+ }
673
+
674
+ /**
675
+ * Deep clone an object (simple JSON-safe clone)
676
+ * @param {Object} obj - Object to clone
677
+ * @returns {Object} Deep copy of the object
678
+ */
679
+ function deepClone(obj) {
680
+ return JSON.parse(JSON.stringify(obj))
681
+ }
682
+
683
+ /**
684
+ * Recursively apply overrides to an entire command tree
685
+ * Creates an enhanced tree with all overrides merged in, suitable for saving as canonical JSON
686
+ * IMPORTANT: This function deep clones the tree to avoid mutating the original
687
+ * @param {Object} tree - The rpk command tree
688
+ * @param {Object} overrides - Resolved overrides object
689
+ * @param {string} [parentPath=''] - Parent command path for recursion
690
+ * @param {boolean} [isRoot=true] - Whether this is the root call (triggers deep clone)
691
+ * @returns {Object} Enhanced tree with overrides applied (new object, original unchanged)
692
+ */
693
+ function applyOverridesToTree(tree, overrides, parentPath = '', isRoot = true) {
694
+ if (!tree) return tree
695
+ if (!overrides) return isRoot ? deepClone(tree) : tree
696
+
697
+ // Deep clone at root level to avoid mutating the original tree
698
+ const workingTree = isRoot ? deepClone(tree) : tree
699
+
700
+ const commandPath = parentPath ? `${parentPath} ${workingTree.name}` : workingTree.name
701
+ const enhanced = mergeCommandOverrides(workingTree, overrides, commandPath)
702
+
703
+ // Recursively process subcommands (not root, already cloned)
704
+ if (enhanced.commands && enhanced.commands.length > 0) {
705
+ enhanced.commands = enhanced.commands.map(subCmd =>
706
+ applyOverridesToTree(subCmd, overrides, commandPath, false)
707
+ )
708
+ }
709
+
710
+ return enhanced
711
+ }
712
+
713
+ /**
714
+ * Decode HTML entities in text
715
+ * @param {string} text - Text with HTML entities
716
+ * @returns {string} Decoded text
717
+ */
718
+ function decodeHtmlEntities(text) {
719
+ if (!text) return text
720
+ return text
721
+ .replace(/&#x3D;/g, '=')
722
+ .replace(/&#x27;/g, "'")
723
+ .replace(/&#x60;/g, '`')
724
+ .replace(/&quot;/g, '"')
725
+ .replace(/&amp;/g, '&')
726
+ .replace(/&lt;/g, '<')
727
+ .replace(/&gt;/g, '>')
728
+ .replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
729
+ .replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
730
+ }
731
+
732
+ /**
733
+ * Convert indented code examples and YAML/config blocks to protected placeholders
734
+ * Detects patterns like:
735
+ * --flag value --other "arg" (command examples)
736
+ * - job_name: test (YAML lists)
737
+ * key: value (YAML nested content)
738
+ *
739
+ * Returns placeholders that must be restored after other transformations.
740
+ *
741
+ * @param {string} text - Text potentially containing indented code
742
+ * @param {Array} codeBlockStore - Array to store extracted code blocks
743
+ * @returns {string} Text with code blocks replaced by placeholders
744
+ */
745
+ function convertIndentedCodeBlocksToAsciiDoc(text, codeBlockStore = []) {
746
+ if (!text) return text
747
+
748
+ const lines = text.split('\n')
749
+ const result = []
750
+ let i = 0
751
+
752
+ while (i < lines.length) {
753
+ const line = lines[i]
754
+
755
+ // Check for indented command example: 2+ spaces then --, rpk, or $ (shell prompt)
756
+ // e.g. " --job-name test --labels ..."
757
+ // e.g. " rpk cluster info"
758
+ // e.g. " $ echo 'command'"
759
+ if (/^[ ]{2,}(--|rpk\s|\$\s)/.test(line)) {
760
+ // Collect all consecutive indented lines that look like command continuation
761
+ // Strip trailing backslashes as we'll add them during join
762
+ // Also strip leading "$ " shell prompt if present
763
+ const codeLines = [line.trim().replace(/^\$\s+/, '').replace(/\s*\\$/, '')]
764
+ let j = i + 1
765
+ while (j < lines.length && /^[ ]{2,}\S/.test(lines[j]) && !/^[ ]{2,}-\s+[A-Z]/.test(lines[j])) {
766
+ // Continue if indented and not a markdown list item (dash followed by capital letter = prose)
767
+ codeLines.push(lines[j].trim().replace(/\s*\\$/, ''))
768
+ j++
769
+ }
770
+
771
+ // Create code block - join with backslash continuation if multi-line
772
+ const joinedCode = codeLines.length > 1
773
+ ? codeLines.join(' \\\n')
774
+ : codeLines[0]
775
+ const codeBlock = `\n[,bash]\n----\n${joinedCode}\n----\n`
776
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
777
+ codeBlockStore.push(codeBlock)
778
+ result.push('')
779
+ result.push(placeholder)
780
+ result.push('')
781
+ i = j
782
+ continue
783
+ }
784
+
785
+ // Check for indented YAML block: 2+ spaces, starts with "- key:" or "key:"
786
+ // and is followed by more indented content with ":" patterns
787
+ // IMPORTANT: Exclude prose definition lists like " - Term: Long description text"
788
+ // YAML has short values or values on next line, prose has long text after colon
789
+ const isYamlStart = (
790
+ // " - key:" with no text or short value after colon (YAML style)
791
+ /^[ ]{2,}-\s+\w+:(?:\s*$|\s+\S{1,20}\s*$)/.test(line) ||
792
+ // " key:" at end of line (YAML block start)
793
+ /^[ ]{2,}\w+:\s*$/.test(line)
794
+ )
795
+ // Exclude if it looks like a prose definition list (colon followed by long text)
796
+ const isProse = /^[ ]{2,}-\s+\w+:\s+\w+\s+\w+\s+\w+/.test(line)
797
+
798
+ if (isYamlStart && !isProse) {
799
+ // Look ahead to see if this is a YAML block (multiple lines with : patterns)
800
+ let j = i
801
+ const potentialYamlLines = []
802
+ let hasMultipleYamlLines = false
803
+
804
+ while (j < lines.length) {
805
+ const currentLine = lines[j]
806
+ // YAML patterns: indented with "- key:", "key:", or just indented continuation
807
+ if (/^[ ]{2,}/.test(currentLine) && (
808
+ /^[ ]*-?\s*\w+:/.test(currentLine) || // key: or - key:
809
+ /^[ ]*-?\s*\[/.test(currentLine) || // - [array]
810
+ /^[ ]{4,}\w+:/.test(currentLine) || // deeply indented key:
811
+ (potentialYamlLines.length > 0 && /^[ ]{4,}\S/.test(currentLine)) // continuation
812
+ )) {
813
+ potentialYamlLines.push(currentLine)
814
+ if (potentialYamlLines.length > 1) hasMultipleYamlLines = true
815
+ j++
816
+ } else if (/^[ ]*$/.test(currentLine) && potentialYamlLines.length > 0) {
817
+ // Blank line - check if YAML continues after
818
+ if (j + 1 < lines.length && /^[ ]{2,}-?\s*\w+:/.test(lines[j + 1])) {
819
+ potentialYamlLines.push(currentLine)
820
+ j++
821
+ } else {
822
+ break
823
+ }
824
+ } else {
825
+ break
826
+ }
827
+ }
828
+
829
+ // If we have a multi-line YAML block, convert to code block
830
+ if (hasMultipleYamlLines && potentialYamlLines.length >= 2) {
831
+ // Find minimum indentation to normalize
832
+ const minIndent = Math.min(...potentialYamlLines
833
+ .filter(l => l.trim())
834
+ .map(l => l.match(/^([ ]*)/)[1].length))
835
+
836
+ const yamlContent = potentialYamlLines.map(l => l.slice(minIndent)).join('\n')
837
+ const codeBlock = `\n[,yaml]\n----\n${yamlContent}\n----\n`
838
+ const placeholder = `__EARLY_CODE_BLOCK_${codeBlockStore.length}__`
839
+ codeBlockStore.push(codeBlock)
840
+ result.push('')
841
+ result.push(placeholder)
842
+ result.push('')
843
+ i = j
844
+ continue
845
+ }
846
+ }
847
+
848
+ // Not a code block pattern, keep line as-is
849
+ result.push(line)
850
+ i++
851
+ }
852
+
853
+ return result.join('\n')
854
+ }
855
+
856
+ /**
857
+ * Convert markdown-style lists to AsciiDoc format
858
+ * Handles: "text:\n - item1\n - item2" -> "text:\n\n* item1\n* item2"
859
+ * @param {string} text - Text with potential markdown lists
860
+ * @returns {string} Text with AsciiDoc lists
861
+ */
862
+ function convertMarkdownLists(text) {
863
+ if (!text) return text
864
+
865
+ // Find and convert markdown-style indented lists
866
+ // Pattern: text followed by newline + spaces + dash + space (first item)
867
+ // then more items with same pattern
868
+ //
869
+ // We use a single regex that:
870
+ // 1. Matches the text before the list (group 1)
871
+ // 2. Captures all the list items together
872
+ // 3. Converts dashes to asterisks and removes indentation
873
+
874
+ // Process the text to find list blocks
875
+ const lines = text.split('\n')
876
+ const result = []
877
+ let i = 0
878
+
879
+ while (i < lines.length) {
880
+ const line = lines[i]
881
+
882
+ // Check if this line starts a markdown-style list item (indented dash)
883
+ if (/^[ \t]+-[ \t]+/.test(line)) {
884
+ // We found a list item - check if we need a blank line before it
885
+ // (need blank line if previous line is not empty and not a list item)
886
+ if (result.length > 0) {
887
+ const prevLine = result[result.length - 1]
888
+ if (prevLine !== '' && !/^\* /.test(prevLine)) {
889
+ result.push('') // Add blank line before list
890
+ }
891
+ }
892
+
893
+ // Convert this item: remove leading whitespace, change - to *
894
+ const converted = line.replace(/^[ \t]+-[ \t]+/, '* ')
895
+ result.push(converted)
896
+ } else {
897
+ result.push(line)
898
+ }
899
+ i++
900
+ }
901
+
902
+ return result.join('\n')
903
+ }
904
+
905
+ /**
906
+ * Convert numbered lists to AsciiDoc list format
907
+ * Detects patterns like:
908
+ * 1. First item
909
+ * 2. Second item
910
+ * 3. Third item
911
+ * Converts to:
912
+ * . First item
913
+ * . Second item
914
+ * . Third item
915
+ *
916
+ * @param {string} text - Text potentially containing numbered lists
917
+ * @param {Object} [options] - Conversion options
918
+ * @param {boolean} [options.skip] - If true, skip list conversion entirely
919
+ * @returns {string} Text with lists converted to AsciiDoc format
920
+ */
921
+ function convertNumberedListsToAsciiDoc(text, options = {}) {
922
+ if (!text || options.skip) return text
923
+
924
+ const lines = text.split('\n')
925
+ const result = []
926
+ let i = 0
927
+
928
+ while (i < lines.length) {
929
+ const line = lines[i]
930
+
931
+ // Check if this line starts a numbered list (indented number followed by period and space)
932
+ // Pattern: spaces, then 1-3 digits, period, space, content
933
+ if (/^[ \t]+(\d{1,3})\.\s+.+/.test(line)) {
934
+ // Found potential list start - collect consecutive numbered items
935
+ const listLines = [line]
936
+ let j = i + 1
937
+ let expectedNum = parseInt(line.match(/^[ \t]+(\d{1,3})\./)[1]) + 1
938
+
939
+ while (j < lines.length) {
940
+ const nextLine = lines[j]
941
+
942
+ // Check if it's the next numbered item
943
+ const nextMatch = nextLine.match(/^[ \t]+(\d{1,3})\.\s+.+/)
944
+ if (nextMatch && parseInt(nextMatch[1]) === expectedNum) {
945
+ listLines.push(nextLine)
946
+ expectedNum++
947
+ j++
948
+ }
949
+ // Allow blank lines within the list
950
+ else if (/^[ \t]*$/.test(nextLine) && j + 1 < lines.length) {
951
+ const afterBlank = lines[j + 1]
952
+ const afterMatch = afterBlank.match(/^[ \t]+(\d{1,3})\.\s+.+/)
953
+ if (afterMatch && parseInt(afterMatch[1]) === expectedNum) {
954
+ listLines.push(nextLine) // Include blank line
955
+ j++
956
+ continue
957
+ } else {
958
+ break
959
+ }
960
+ } else {
961
+ break
962
+ }
963
+ }
964
+
965
+ // Convert to AsciiDoc list format if we have at least 2 items
966
+ if (listLines.length >= 2) {
967
+ // Add blank line before list if previous line isn't blank
968
+ if (result.length > 0 && result[result.length - 1].trim() !== '') {
969
+ result.push('')
970
+ }
971
+
972
+ // Convert each numbered item to AsciiDoc format (. instead of 1., 2., etc.)
973
+ for (const listLine of listLines) {
974
+ if (/^[ \t]*$/.test(listLine)) {
975
+ result.push(listLine) // Keep blank lines
976
+ } else {
977
+ // Remove indentation and number, replace with '.'
978
+ const content = listLine.replace(/^[ \t]+\d{1,3}\.\s+/, '')
979
+ result.push(`. ${content}`)
980
+ }
981
+ }
982
+
983
+ i = j
984
+ continue
985
+ }
986
+ }
987
+
988
+ // Not a list, keep the line as-is
989
+ result.push(line)
990
+ i++
991
+ }
992
+
993
+ return result.join('\n')
994
+ }
995
+
996
+ /**
997
+ * Convert indented bulleted definition lists to AsciiDoc-compliant format
998
+ * Detects patterns like:
999
+ * * Term: description text that may
1000
+ * continue on next line
1001
+ * * Another: more description
1002
+ *
1003
+ * Converts to proper AsciiDoc bullet list with bold terms:
1004
+ * * *Term*: description text
1005
+ * * *Another*: more description
1006
+ *
1007
+ * @param {string} text - Text potentially containing definition lists
1008
+ * @param {Object} [options] - Conversion options
1009
+ * @param {boolean} [options.skip] - If true, skip conversion entirely
1010
+ * @returns {string} Text with definition lists formatted as AsciiDoc
1011
+ */
1012
+ function convertBulletedDefinitionListsToAsciiDoc(text, options = {}) {
1013
+ if (!text || options.skip) return text
1014
+
1015
+ const lines = text.split('\n')
1016
+ const result = []
1017
+ let i = 0
1018
+
1019
+ while (i < lines.length) {
1020
+ const line = lines[i]
1021
+
1022
+ // Check for indented bullet with term:description pattern
1023
+ // Pattern: spaces, bullet (* or -), optional space, word(s), colon, description
1024
+ const bulletMatch = line.match(/^(\s{2,})([*-])\s+([A-Za-z][A-Za-z0-9_]*(?:\s+[A-Za-z][A-Za-z0-9_]*)*):\s+(.*)$/)
1025
+
1026
+ if (bulletMatch) {
1027
+ const indent = bulletMatch[1]
1028
+ const bullet = bulletMatch[2]
1029
+ // Collect all consecutive bullets at same level with term: pattern
1030
+ const definitionItems = []
1031
+ let j = i
1032
+
1033
+ while (j < lines.length) {
1034
+ const currentLine = lines[j]
1035
+ const currentMatch = currentLine.match(/^(\s{2,})([*-])\s+([A-Za-z][A-Za-z0-9_]*(?:\s+[A-Za-z][A-Za-z0-9_]*)*):\s+(.*)$/)
1036
+
1037
+ if (currentMatch && currentMatch[1] === indent && currentMatch[2] === bullet) {
1038
+ // Start new definition item
1039
+ definitionItems.push({
1040
+ term: currentMatch[3],
1041
+ description: currentMatch[4]
1042
+ })
1043
+ j++
1044
+
1045
+ // Check for continuation lines (more indented than the bullet)
1046
+ while (j < lines.length) {
1047
+ const nextLine = lines[j]
1048
+ // Continuation: starts with more indentation than the bullet, no bullet char
1049
+ if (nextLine.match(/^\s+/) && !nextLine.match(/^\s*[*-]\s/) && nextLine.trim()) {
1050
+ const continuationIndent = nextLine.match(/^(\s*)/)[1].length
1051
+ if (continuationIndent > indent.length) {
1052
+ // Append to description
1053
+ definitionItems[definitionItems.length - 1].description += ' ' + nextLine.trim()
1054
+ j++
1055
+ } else {
1056
+ break
1057
+ }
1058
+ } else if (/^\s*$/.test(nextLine)) {
1059
+ // Blank line - might be between items, peek ahead
1060
+ if (j + 1 < lines.length && lines[j + 1].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[*-]\\s+[A-Za-z]`))) {
1061
+ j++ // Skip blank line between definition items
1062
+ } else {
1063
+ break
1064
+ }
1065
+ } else {
1066
+ break
1067
+ }
1068
+ }
1069
+ } else {
1070
+ break
1071
+ }
1072
+ }
1073
+
1074
+ // Only convert if we have 2+ items (makes sense as a list)
1075
+ if (definitionItems.length >= 2) {
1076
+ result.push('')
1077
+ for (const item of definitionItems) {
1078
+ // Format as AsciiDoc bullet with backticked term
1079
+ result.push(`* \`${item.term}\`: ${item.description}`)
1080
+ }
1081
+ result.push('')
1082
+ i = j
1083
+ continue
1084
+ }
1085
+ }
1086
+
1087
+ // Not a definition list, keep line as-is
1088
+ result.push(line)
1089
+ i++
1090
+ }
1091
+
1092
+ return result.join('\n')
1093
+ }
1094
+
1095
+ /**
1096
+ * Convert indented columnar text to AsciiDoc tables
1097
+ * Detects patterns like:
1098
+ * value1 description of value1
1099
+ * value2 description of value2
1100
+ *
1101
+ * @param {string} text - Text potentially containing indented tables
1102
+ * @param {Object} [options] - Conversion options
1103
+ * @param {boolean} [options.skip] - If true, skip table conversion entirely
1104
+ * @returns {string} Text with tables converted to AsciiDoc format
1105
+ */
1106
+ function convertIndentedTablesToAsciiDoc(text, options = {}) {
1107
+ if (!text || options.skip) return text
1108
+
1109
+ // Skip conversion if text contains code patterns (struct definitions, type declarations)
1110
+ if (/\b(type|struct|interface|class)\s+\w+\s*(struct\s*)?\{/.test(text)) {
1111
+ return text
1112
+ }
1113
+
1114
+ const lines = text.split('\n')
1115
+ const result = []
1116
+ let i = 0
1117
+
1118
+ while (i < lines.length) {
1119
+ const line = lines[i]
1120
+
1121
+ // Check if this line starts a potential table (indented, has content)
1122
+ // Pattern: starts with spaces, has non-space content, then 2+ spaces, then more content
1123
+ if (/^[ \t]{2,}[^ \t].* /.test(line)) {
1124
+ // Found potential table start - look ahead for consecutive similar lines
1125
+ const tableLines = [line]
1126
+ let j = i + 1
1127
+
1128
+ while (j < lines.length) {
1129
+ const nextLine = lines[j]
1130
+ // Continue if line is indented and has columnar structure
1131
+ if (/^[ \t]{2,}[^ \t].* /.test(nextLine)) {
1132
+ tableLines.push(nextLine)
1133
+ j++
1134
+ }
1135
+ // Or if it's a continuation line (indented more deeply, no column separator)
1136
+ else if (/^[ \t]{4,}[^ \t]/.test(nextLine) && !/ /.test(nextLine.trim())) {
1137
+ tableLines.push(nextLine)
1138
+ j++
1139
+ }
1140
+ // Or blank line followed by another table row
1141
+ else if (/^[ \t]*$/.test(nextLine) && j + 1 < lines.length && /^[ \t]{2,}[^ \t].* /.test(lines[j + 1])) {
1142
+ tableLines.push(nextLine)
1143
+ j++
1144
+ } else {
1145
+ break
1146
+ }
1147
+ }
1148
+
1149
+ // Only convert if we have 2+ rows (actual table)
1150
+ if (tableLines.length >= 2) {
1151
+ // Parse the table rows
1152
+ const parsedRows = []
1153
+ let currentRow = null
1154
+
1155
+ for (const tableLine of tableLines) {
1156
+ if (/^[ \t]*$/.test(tableLine)) continue // Skip blank lines
1157
+
1158
+ const trimmed = tableLine.trim()
1159
+
1160
+ // Check if this is a continuation line (no column separator)
1161
+ if (currentRow && !/ /.test(trimmed)) {
1162
+ // Append to the last column of the current row
1163
+ const lastColIndex = currentRow.length - 1
1164
+ currentRow[lastColIndex] += ' ' + trimmed
1165
+ } else {
1166
+ // Extract columns: split on 2+ spaces
1167
+ const columns = trimmed.split(/ +/)
1168
+
1169
+ if (columns.length >= 2) {
1170
+ currentRow = columns
1171
+ parsedRows.push(currentRow)
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ if (parsedRows.length >= 2) {
1177
+ // Convert to AsciiDoc table
1178
+ result.push('')
1179
+ result.push('[cols="1m,1a"]')
1180
+ result.push('|===')
1181
+ result.push('|Value |Description')
1182
+ result.push('')
1183
+ for (const [value, ...descParts] of parsedRows) {
1184
+ // Remove trailing colon from value (e.g., "Organization:" -> "Organization")
1185
+ const cleanValue = value.replace(/:$/, '')
1186
+ const desc = descParts.join(' ')
1187
+ result.push(`|${cleanValue} |${desc}`)
1188
+ }
1189
+ result.push('|===')
1190
+ result.push('')
1191
+
1192
+ i = j
1193
+ continue
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ // Not a table, keep the line as-is
1199
+ result.push(line)
1200
+ i++
1201
+ }
1202
+
1203
+ return result.join('\n')
1204
+ }
1205
+
1206
+ /**
1207
+ * Format description by adding backticks around flags and code
1208
+ * @param {string} desc - Description text
1209
+ * @param {Object} [customTransformations] - Optional custom text transformations from overrides
1210
+ * @param {Object} [options] - Formatting options
1211
+ * @param {boolean} [options.skipTableConversion] - If true, skip automatic table conversion
1212
+ * @param {boolean} [options.skipListConversion] - If true, skip automatic list conversion
1213
+ * @returns {string} Formatted description
1214
+ */
1215
+ function formatDescription(desc, customTransformations = null, options = {}) {
1216
+ if (!desc) return ''
1217
+ if (typeof desc !== 'string') return String(desc)
1218
+
1219
+ let preProcessed = desc
1220
+
1221
+ // === STEP 1: Apply merge patterns for broken inline code spans ===
1222
+ // These must run BEFORE protecting inline code, so they can fix patterns like:
1223
+ // `rpk command `-flag` rest` -> `rpk command -flag rest`
1224
+ if (customTransformations?.replacements) {
1225
+ const mergePatterns = customTransformations.replacements.filter(rule =>
1226
+ rule.description && rule.description.toLowerCase().includes('merge') &&
1227
+ rule.description.toLowerCase().includes('inline code')
1228
+ )
1229
+
1230
+ for (const rule of mergePatterns) {
1231
+ try {
1232
+ const flags = rule.flags || 'g'
1233
+ const regex = new RegExp(rule.pattern, flags)
1234
+ preProcessed = preProcessed.replace(regex, rule.replacement)
1235
+ } catch (err) {
1236
+ console.warn(`⚠ Invalid merge pattern: ${rule.pattern}`)
1237
+ if (rule.description) console.warn(` Description: ${rule.description}`)
1238
+ console.warn(` Error: ${err.message}`)
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ // === STEP 1b: Detect and protect multi-line code/YAML blocks VERY EARLY ===
1244
+ // This must happen BEFORE single-line rpk conversion (STEP 1c) so multi-line
1245
+ // command examples are detected as blocks, not broken into pieces
1246
+ const earlyCodeBlocks = []
1247
+ preProcessed = convertIndentedCodeBlocksToAsciiDoc(preProcessed, earlyCodeBlocks)
1248
+
1249
+ // === STEP 1c: Convert SINGLE-LINE indented commands to backticked inline code ===
1250
+ // Only applies to commands NOT already in code blocks (protected by STEP 1b)
1251
+ // This must happen BEFORE text transformations so paths inside commands don't get
1252
+ // individually backticked, creating nested placeholders that fail to restore.
1253
+ // Source often uses indentation for example commands:
1254
+ // rpk cluster partitions balancer-status
1255
+ // rpk cluster license set --path /home/file.license
1256
+ // Convert to:
1257
+ // `rpk cluster partitions balancer-status`
1258
+ // `rpk cluster license set --path /home/file.license`
1259
+ preProcessed = preProcessed.replace(/^[ \t]+(rpk\s+.+?)$/gm, (match, command) => {
1260
+ // Skip if this line is a placeholder (already converted to code block)
1261
+ if (command.startsWith('__EARLY_CODE_BLOCK_')) return match
1262
+ return `\`${command}\``
1263
+ })
1264
+
1265
+ // Also convert indented non-rpk shell commands (starting with common commands)
1266
+ // These are shell snippets that should be code, not prose
1267
+ preProcessed = preProcessed.replace(/^[ \t]+((?:source|command|echo|export|brew|curl|wget|cat|mkdir|chmod|sudo)\s+.+?)$/gm, (match, command) => {
1268
+ // Skip if this line is a placeholder (already converted to code block)
1269
+ if (command.startsWith('__EARLY_CODE_BLOCK_')) return match
1270
+ return `\`${command}\``
1271
+ })
1272
+
1273
+ // === STEP 2: Protect code blocks, xrefs, and inline code ===
1274
+ // Protect code blocks FIRST (before any transformations)
1275
+ const codeBlocks = []
1276
+ preProcessed = preProcessed.replace(/(\[,?[a-z]*\]\n)?----\n[\s\S]*?\n----/g, (match) => {
1277
+ const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`
1278
+ codeBlocks.push(match)
1279
+ return placeholder
1280
+ })
1281
+
1282
+ // Protect xref paths BEFORE inline code (xrefs may contain backticked link text)
1283
+ // This ensures the entire xref including link text is protected as a unit
1284
+ const xrefs = []
1285
+ preProcessed = preProcessed.replace(/xref:[^\[]+\[[^\]]*\]/g, (match) => {
1286
+ const placeholder = `__XREF_${xrefs.length}__`
1287
+ xrefs.push(match)
1288
+ return placeholder
1289
+ })
1290
+
1291
+ // Protect inline code (backticked content)
1292
+ const inlineCode = []
1293
+ preProcessed = preProcessed.replace(/`[^`]+`/g, (match) => {
1294
+ const placeholder = `__INLINE_CODE_${inlineCode.length}__`
1295
+ inlineCode.push(match)
1296
+ return placeholder
1297
+ })
1298
+
1299
+ // === STEP 3: Apply remaining text transformations ===
1300
+ // Apply text transformations from overrides (textTransformations.replacements)
1301
+ // These are applied AFTER protecting code blocks so they don't affect code content
1302
+ // Note: Merge patterns were already applied in STEP 1
1303
+ if (customTransformations?.replacements) {
1304
+ const nonMergePatterns = customTransformations.replacements.filter(rule =>
1305
+ !rule.description || !rule.description.toLowerCase().includes('merge') ||
1306
+ !rule.description.toLowerCase().includes('inline code')
1307
+ )
1308
+
1309
+ for (const rule of nonMergePatterns) {
1310
+ try {
1311
+ const flags = rule.flags || 'g'
1312
+ const regex = new RegExp(rule.pattern, flags)
1313
+ preProcessed = preProcessed.replace(regex, rule.replacement)
1314
+ } catch (err) {
1315
+ console.warn(`⚠ Invalid replacement pattern: ${rule.pattern}`)
1316
+ if (rule.description) console.warn(` Description: ${rule.description}`)
1317
+ console.warn(` Error: ${err.message}`)
1318
+ }
1319
+ }
1320
+ }
1321
+
1322
+ // Re-protect inline code after text transformations
1323
+ // Some transformations (like converting "rpk ..." to `rpk ...`) create new inline code
1324
+ // that needs protection before subsequent patterns run
1325
+ preProcessed = preProcessed.replace(/`[^`]+`/g, (match) => {
1326
+ const placeholder = `__INLINE_CODE_${inlineCode.length}__`
1327
+ inlineCode.push(match)
1328
+ return placeholder
1329
+ })
1330
+
1331
+ // Convert numbered lists to AsciiDoc list format
1332
+ // This must be done BEFORE table conversion (lists are simpler patterns)
1333
+ preProcessed = convertNumberedListsToAsciiDoc(preProcessed, { skip: options.skipListConversion })
1334
+
1335
+ // Convert indented bulleted definition lists to AsciiDoc-compliant format
1336
+ // Pattern: " * Term: description" becomes "* `Term`: description"
1337
+ preProcessed = convertBulletedDefinitionListsToAsciiDoc(preProcessed, { skip: options.skipListConversion })
1338
+
1339
+ // Convert indented columnar text to AsciiDoc tables
1340
+ preProcessed = convertIndentedTablesToAsciiDoc(preProcessed, { skip: options.skipTableConversion })
1341
+
1342
+ let protectedDesc = preProcessed
1343
+
1344
+ // Apply custom inline code patterns (from overrides textTransformations.inlineCode)
1345
+ // Applied AFTER protecting existing code blocks/inline code to avoid double-wrapping
1346
+ if (customTransformations?.inlineCode) {
1347
+ for (const rule of customTransformations.inlineCode) {
1348
+ try {
1349
+ // Handle both string and object format
1350
+ const pattern = typeof rule === 'string' ? rule : rule.pattern
1351
+ const replacement = rule.replacement || '`$&`' // $& = entire match
1352
+ const flags = (typeof rule === 'object' ? rule.flags : null) || 'g'
1353
+ const regex = new RegExp(pattern, flags)
1354
+
1355
+ protectedDesc = protectedDesc.replace(regex, (match, ...args) => {
1356
+ // Skip if this is a placeholder (already protected content)
1357
+ if (match.startsWith('__INLINE_CODE_') || match.startsWith('__CODE_BLOCK_')) {
1358
+ return match
1359
+ }
1360
+
1361
+ // Apply the transformation
1362
+ if (rule.replacement) {
1363
+ // Custom replacement with capture group support
1364
+ return rule.replacement
1365
+ .replace(/\$(\d+)/g, (_, n) => args[parseInt(n) - 1] || '')
1366
+ .replace(/\$&/g, match)
1367
+ } else {
1368
+ // Default: wrap in backticks
1369
+ return `\`${match}\``
1370
+ }
1371
+ })
1372
+ } catch (err) {
1373
+ const patternStr = typeof rule === 'string' ? rule : rule.pattern
1374
+ console.warn(`⚠ Invalid inline code pattern: ${patternStr}`)
1375
+ if (typeof rule === 'object' && rule.description) {
1376
+ console.warn(` Description: ${rule.description}`)
1377
+ }
1378
+ console.warn(` Error: ${err.message}`)
1379
+ }
1380
+ }
1381
+ }
1382
+
1383
+ // Re-protect any new inline code created by inlineCode transformations
1384
+ // This ensures newly backticked content is preserved through subsequent transformations
1385
+ protectedDesc = protectedDesc.replace(/`[^`]+`/g, (match) => {
1386
+ const placeholder = `__INLINE_CODE_${inlineCode.length}__`
1387
+ inlineCode.push(match)
1388
+ return placeholder
1389
+ })
1390
+
1391
+ // Known top-level rpk subcommands (for accurate command detection)
1392
+ const rpkSubcommands = new Set([
1393
+ 'ai', 'check', 'cloud', 'cluster', 'connect', 'container', 'debug',
1394
+ 'generate', 'group', 'help', 'iotune', 'oxla', 'plugin', 'profile',
1395
+ 'redpanda', 'registry', 'security', 'shadow', 'topic', 'transform', 'version'
1396
+ ])
1397
+
1398
+ // Common prose words that indicate "rpk X" is NOT a command
1399
+ const proseWords = new Set([
1400
+ 'a', 'an', 'and', 'or', 'the', 'is', 'was', 'are', 'were', 'will',
1401
+ 'can', 'could', 'may', 'might', 'should', 'would', 'has', 'have',
1402
+ 'had', 'does', 'do', 'did', 'with', 'from', 'to', 'for', 'of', 'by',
1403
+ 'that', 'this', 'these', 'those', 'it', 'its'
1404
+ ])
1405
+
1406
+ let result = convertMarkdownLists(decodeHtmlEntities(protectedDesc))
1407
+ // === NORMALIZE SOURCE DATA ISSUES ===
1408
+ // Fix fragmented backticks like `rpk` `--version` → rpk --version
1409
+ .replace(/`rpk`\s*`(--?[a-z][-a-z0-9]*)`/gi, 'rpk $1')
1410
+ // Remove single quotes around flags: '--flag' → --flag (backticks added later)
1411
+ // Also handles '--flag/-f', '--flag help', and similar patterns
1412
+ .replace(/'(--[a-z][-a-z0-9]*(?:\/-[a-z])?(?:\s+\w+)?)'/gi, '$1')
1413
+ .replace(/'(-[a-z])'/gi, '$1')
1414
+ // === STYLE GUIDE COMPLIANCE ===
1415
+ .replace(/\be\.g\.\s*/gi, 'for example, ')
1416
+ .replace(/\bi\.e\.\s*/gi, 'that is, ')
1417
+ // === TYPO CORRECTIONS ===
1418
+ // Fix "an" before consonant sounds (common source typos)
1419
+ .replace(/\ban\s+(prod|dev|test|local|remote|new|cluster|config|file)\b/gi, 'a $1')
1420
+ // Note: File name backticking moved after file path processing
1421
+ // === CAPITALIZATION ===
1422
+ // Capitalize "redpanda" when clearly referring to the product (not YAML config sections or commands)
1423
+ // Match: "redpanda" followed by product-context words
1424
+ .replace(/(?<!rpk\s)(?<![`\w-])redpanda(?=\s+(?:requirements?|will|settings?|binar(?:y|ies)|process(?:es)?|nodes?|has|cluster|Console|data|is|installations?|starts?|reverts?|expects?|ID|deployment))/gi, 'Redpanda')
1425
+ // Special case: "redpanda config file" (but not "rpk redpanda config" which is a command)
1426
+ .replace(/(?<!rpk\s)(?<![`\w-])redpanda\s+config\s+file/gi, 'Redpanda config file')
1427
+ // Capitalize at start of sentence
1428
+ .replace(/(^|[.!?]\s+)redpanda\b(?!\.yaml|data\/|section|-)/gim, '$1Redpanda')
1429
+ // Fix product name: "Redpanda cloud" → "Redpanda Cloud" (product name)
1430
+ .replace(/Redpanda\s+cloud\b/g, 'Redpanda Cloud')
1431
+
1432
+ // === RPK COMMAND FORMATTING (context-aware) ===
1433
+ // Process "rpk X" patterns based on what follows
1434
+ result = result.replace(/(?<![`\w])rpk(\s+)([a-z][-a-z0-9]*)(?:\s+|(?=[.,;:!?)'"}\]]|$))/gi, (match, space, word, offset, str) => {
1435
+ const lowerWord = word.toLowerCase()
1436
+
1437
+ // Skip wrapping when "rpk profile" refers to config profile, not the command
1438
+ // e.g., "your rpk profile" or "an rpk profile"
1439
+ if (lowerWord === 'profile') {
1440
+ const before = str.slice(Math.max(0, offset - 10), offset)
1441
+ if (/\b(your|an|the)\s+$/i.test(before)) {
1442
+ return match // Don't wrap - it's referring to the config profile concept
1443
+ }
1444
+ }
1445
+
1446
+ // If followed by a prose word, only wrap "rpk"
1447
+ if (proseWords.has(lowerWord)) {
1448
+ return '`rpk`' + space + word + (match.endsWith(word) ? '' : match.slice(match.lastIndexOf(word) + word.length))
1449
+ }
1450
+
1451
+ // If it's a known rpk subcommand, check what follows to decide
1452
+ if (rpkSubcommands.has(lowerWord)) {
1453
+ // Look ahead to see if there's more text after this
1454
+ const remaining = str.slice(offset + match.length).trimStart()
1455
+
1456
+ // If followed by a flag, wrap "rpk subcommand" together (will be handled by flag regex)
1457
+ if (/^--?[a-z]/i.test(remaining)) {
1458
+ return match // Let the rpk+flag regex handle it
1459
+ }
1460
+
1461
+ // If followed by more words, check if they look like prose or commands
1462
+ const nextWordMatch = remaining.match(/^([a-z][-a-z0-9]*)/i)
1463
+ if (nextWordMatch) {
1464
+ const nextWord = nextWordMatch[1].toLowerCase()
1465
+ // Words ending in common noun suffixes are likely prose, not commands
1466
+ // e.g., "authentications", "clusters", "configurations", "settings"
1467
+ if (/(?:tions?|ments?|ings?|ers?|ors?|ies|s)$/i.test(nextWord) && !rpkSubcommands.has(nextWord)) {
1468
+ // It's prose like "rpk cloud authentications" - just wrap rpk
1469
+ return '`rpk`' + space + word + (match.endsWith(word) ? '' : match.slice(match.lastIndexOf(word) + word.length))
1470
+ }
1471
+ }
1472
+
1473
+ // At end of phrase or followed by punctuation - wrap together
1474
+ if (!remaining || /^[.,;:!?)'"}\]]/.test(remaining)) {
1475
+ return '`rpk ' + word + '`' + (match.endsWith(word) ? '' : match.slice(match.lastIndexOf(word) + word.length))
1476
+ }
1477
+
1478
+ // Default: just wrap rpk alone to be safe
1479
+ return '`rpk`' + space + word + (match.endsWith(word) ? '' : match.slice(match.lastIndexOf(word) + word.length))
1480
+ }
1481
+
1482
+ // Unknown word - conservatively just wrap "rpk"
1483
+ return '`rpk`' + space + word + (match.endsWith(word) ? '' : match.slice(match.lastIndexOf(word) + word.length))
1484
+ })
1485
+
1486
+ // Wrap rpk with flags: rpk --version, rpk -v
1487
+ result = result.replace(/(?<![`\w])rpk\s+(--?[a-z][-a-z0-9]*)/gi, '`rpk $1`')
1488
+
1489
+ // Add backticks around standalone flags (with optional =value)
1490
+ // Don't wrap flags that are inside backticks OR immediately after "rpk "
1491
+ // Split by backticks to process only content outside of backticked sections
1492
+ const flagParts = result.split('`')
1493
+ result = flagParts.map((part, i) => {
1494
+ // Even indices are outside backticks, odd indices are inside
1495
+ if (i % 2 === 0) {
1496
+ // Outside backticks - apply flag wrapping
1497
+ // Include =value when present (e.g., --format=json, --var=KEY=VALUE)
1498
+ return part.replace(/(?<!rpk\s)(--[a-z][-a-z0-9]*(?:=[^\s,;.)\]]+)?)/gi, '`$1`')
1499
+ } else {
1500
+ // Inside backticks - don't modify
1501
+ return part
1502
+ }
1503
+ }).join('`')
1504
+ result = result.replace(/(?<!`|-)(-[a-zA-Z])(?![a-zA-Z-])/g, '`$1`')
1505
+
1506
+ // Add backticks around environment variables
1507
+ result = result.replace(/(?<!`)(\$[A-Z_][A-Z0-9_]*)/g, '`$1`')
1508
+
1509
+ // Merge backticked environment variables with adjacent paths
1510
+ // e.g., `$HOME`/.local/bin → `$HOME/.local/bin`
1511
+ result = result.replace(/`(\$[A-Z_][A-Z0-9_]*)`(\/[^\s`]+)/g, '`$1$2`')
1512
+
1513
+ // Add backticks around file paths (but not if already backticked)
1514
+ // Must check both the slash and the path aren't already inside backticks
1515
+ result = result.replace(/(?<![`/])(\/(?:etc|var|usr|home|tmp)\/[^\s,)`]+)/g, '`$1`')
1516
+ result = result.replace(/(?<![`/])((?:etc|var|usr|home|tmp)\/[^\s,)`]+)/g, '`$1`')
1517
+
1518
+ // Add backticks around home directory paths (~/.bashrc, ~/.zshrc, ~/.config/...)
1519
+ result = result.replace(/(?<![`\w])(~\/\.[^\s,;:)`]+)/g, '`$1`')
1520
+
1521
+ // Add backticks around common package names
1522
+ result = result.replace(/(?<![`\w-])(bash-completion)(?![`\w-])/g, '`$1`')
1523
+
1524
+ // Add backticks around standalone file names (not inside paths)
1525
+ result = result.replace(/(?<![`/])(redpanda\.yaml|rpk\.yaml)(?!`)/gi, '`$1`')
1526
+
1527
+ // Add backticks around standalone "rpk" (at end of phrase, before punctuation)
1528
+ // Also exclude rpk inside paths (preceded by /)
1529
+ result = result.replace(/(?<![`\w/])rpk(?=[\s]*[.,;:!?)'"}\]]|[\s]*$)/g, '`rpk`')
1530
+
1531
+ // Convert GitHub issue/PR references to clickable links
1532
+ // Pattern: #NNNN (4+ digits) when not inside backticks or code
1533
+ // e.g., "(see #2904)" → "(see https://github.com/redpanda-data/redpanda/issues/2904[#2904])"
1534
+ result = result.replace(/(?<![`\w])#(\d{4,})(?![`\w])/g, 'https://github.com/redpanda-data/redpanda/issues/$1[#$1]')
1535
+
1536
+ // Restore early code blocks FIRST (from indented command/YAML detection)
1537
+ // This must happen before inline code restoration so that placeholders
1538
+ // inside the early code blocks (like __INLINE_CODE_X__) get resolved
1539
+ earlyCodeBlocks.forEach((block, i) => {
1540
+ result = result.replace(`__EARLY_CODE_BLOCK_${i}__`, block)
1541
+ })
1542
+
1543
+ // Restore inline code
1544
+ inlineCode.forEach((code, i) => {
1545
+ result = result.replace(`__INLINE_CODE_${i}__`, code)
1546
+ })
1547
+
1548
+ // Restore xrefs
1549
+ xrefs.forEach((xref, i) => {
1550
+ result = result.replace(`__XREF_${i}__`, xref)
1551
+ })
1552
+
1553
+ // Restore code blocks
1554
+ codeBlocks.forEach((block, i) => {
1555
+ result = result.replace(`__CODE_BLOCK_${i}__`, block)
1556
+ })
1557
+
1558
+ // Clean up double backticks (moved AFTER restoration to catch nested backticks)
1559
+ result = result.replace(/``+/g, '`')
1560
+
1561
+ // Remove trailing period after backticked commands on their own line
1562
+ // e.g., "`rpk cluster status`." → "`rpk cluster status`"
1563
+ // Only consume trailing spaces, not newlines (to preserve paragraph breaks)
1564
+ result = result.replace(/^(`[^`]+`)\.[ \t]*$/gm, '$1')
1565
+
1566
+ return result
1567
+ }
1568
+
1569
+ /**
1570
+ * Format usage string - convert [PLACEHOLDER] to <placeholder>
1571
+ * @param {string} usage - Usage string from rpk
1572
+ * @returns {string} Formatted usage string
1573
+ */
1574
+ function formatUsage(usage) {
1575
+ if (!usage) return ''
1576
+ // Convert [UPPERCASE-PLACEHOLDER] to <lowercase-placeholder>
1577
+ // But keep [flags] as-is since that's the standard convention
1578
+ return usage.replace(/\[([A-Z][-A-Z0-9_]*(?:\.\.\.)?)\]/g, (match, placeholder) => {
1579
+ return '<' + placeholder.toLowerCase() + '>'
1580
+ })
1581
+ }
1582
+
1583
+ /**
1584
+ * Format EXAMPLES section - convert indented commands and inline code to AsciiDoc code blocks
1585
+ * @param {string} text - Examples text with indented commands or inline code
1586
+ * @returns {string} Formatted examples with code blocks
1587
+ */
1588
+ function formatExamples(text) {
1589
+ if (!text) return text
1590
+
1591
+ // Convert both indented commands and inline backticked commands to AsciiDoc code blocks
1592
+ // Pattern 1: description line, then indented command (starts with 2+ spaces)
1593
+ // Pattern 2: description line ending with colon/period, followed by `rpk command`
1594
+ const lines = text.split('\n')
1595
+ const result = []
1596
+ let i = 0
1597
+
1598
+ while (i < lines.length) {
1599
+ const line = lines[i]
1600
+
1601
+ // Check if next line is an indented command (2+ spaces followed by non-space)
1602
+ if (i + 1 < lines.length && /^\s{2,}\S/.test(lines[i + 1])) {
1603
+ // Current line is description, next line is command
1604
+ result.push(line)
1605
+ result.push('')
1606
+ result.push('[,bash]')
1607
+ result.push('----')
1608
+ result.push(lines[i + 1].trim())
1609
+ result.push('----')
1610
+ i += 2
1611
+ } else if (/^\s{2,}\S/.test(line)) {
1612
+ // Standalone indented command without preceding description
1613
+ result.push('[,bash]')
1614
+ result.push('----')
1615
+ result.push(line.trim())
1616
+ result.push('----')
1617
+ i++
1618
+ } else if (/^\s*`rpk .+`\s*$/.test(line)) {
1619
+ // Inline backticked command on its own line - convert to code block
1620
+ const command = line.trim().replace(/^`|`$/g, '')
1621
+ result.push('')
1622
+ result.push('[,bash]')
1623
+ result.push('----')
1624
+ result.push(command)
1625
+ result.push('----')
1626
+ i++
1627
+ } else if (i + 1 < lines.length && /^\s*`rpk .+`\s*$/.test(lines[i + 1])) {
1628
+ // Description followed by backticked command on next line
1629
+ // Remove trailing colon from description if present
1630
+ const description = line.replace(/:$/, '.')
1631
+ const command = lines[i + 1].trim().replace(/^`|`$/g, '')
1632
+ result.push(description)
1633
+ result.push('')
1634
+ result.push('[,bash]')
1635
+ result.push('----')
1636
+ result.push(command)
1637
+ result.push('----')
1638
+ i += 2
1639
+ } else {
1640
+ // Regular line (description, blank line, etc.)
1641
+ result.push(line)
1642
+ i++
1643
+ }
1644
+ }
1645
+
1646
+ return result.join('\n')
1647
+ }
1648
+
1649
+ /**
1650
+ * Filter out specific examples from raw EXAMPLES section content
1651
+ * @param {string} text - Raw examples text (before formatting)
1652
+ * @param {string[]} excludePatterns - Array of regex patterns to exclude
1653
+ * @returns {string} Filtered examples text
1654
+ */
1655
+ function filterExamples(text, excludePatterns) {
1656
+ if (!text || !excludePatterns || excludePatterns.length === 0) return text
1657
+
1658
+ const patterns = excludePatterns.map(p => new RegExp(p, 'i'))
1659
+ const lines = text.split('\n')
1660
+ const filteredLines = []
1661
+ let i = 0
1662
+
1663
+ while (i < lines.length) {
1664
+ const line = lines[i]
1665
+ const nextLine = lines[i + 1] || ''
1666
+
1667
+ // Check if next line is an indented command (2+ spaces)
1668
+ if (/^\s{2,}\S/.test(nextLine)) {
1669
+ // This is a description + command pair
1670
+ const shouldExclude = patterns.some(pattern =>
1671
+ pattern.test(line) || pattern.test(nextLine.trim())
1672
+ )
1673
+ if (!shouldExclude) {
1674
+ filteredLines.push(line)
1675
+ filteredLines.push(nextLine)
1676
+ }
1677
+ i += 2
1678
+ } else if (/^\s{2,}\S/.test(line)) {
1679
+ // Standalone indented command
1680
+ const shouldExclude = patterns.some(pattern => pattern.test(line.trim()))
1681
+ if (!shouldExclude) {
1682
+ filteredLines.push(line)
1683
+ }
1684
+ i++
1685
+ } else if (line.trim() === '') {
1686
+ // Blank line - keep it unless previous content was excluded
1687
+ filteredLines.push(line)
1688
+ i++
1689
+ } else {
1690
+ // Regular line (description without following command, etc.)
1691
+ const shouldExclude = patterns.some(pattern => pattern.test(line))
1692
+ if (!shouldExclude) {
1693
+ filteredLines.push(line)
1694
+ }
1695
+ i++
1696
+ }
1697
+ }
1698
+
1699
+ // Clean up multiple consecutive blank lines
1700
+ return filteredLines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
1701
+ }
1702
+
1703
+ /**
1704
+ * Parse ALL CAPS sections from description
1705
+ * @param {string} desc - Description text
1706
+ * @returns {Object} { mainDescription, sections }
1707
+ */
1708
+ function parseDescriptionSections(desc) {
1709
+ if (!desc) return { mainDescription: '', sections: {} }
1710
+ if (typeof desc !== 'string') {
1711
+ return { mainDescription: String(desc), sections: {} }
1712
+ }
1713
+
1714
+ const lines = desc.split('\n')
1715
+ const sections = {}
1716
+ let currentSection = null
1717
+ let currentContent = []
1718
+ const mainLines = []
1719
+
1720
+ for (const line of lines) {
1721
+ // Check for ALL CAPS header (at least 2 chars, possibly with spaces, hyphens, slashes, or ampersands)
1722
+ // Examples: "FIELDS", "BALANCER STATUS", "PRODUCER ID & EPOCH"
1723
+ const headerMatch = line.match(/^([A-Z][A-Z\s\-\/&]{0,}[A-Z])$/)
1724
+ if (headerMatch) {
1725
+ // Save previous section
1726
+ if (currentSection) {
1727
+ // Preserve indentation: only remove leading/trailing blank lines, not spaces
1728
+ sections[currentSection] = trimBlankLines(currentContent.join('\n'))
1729
+ }
1730
+ currentSection = headerMatch[1].trim()
1731
+ currentContent = []
1732
+ } else if (currentSection) {
1733
+ currentContent.push(line)
1734
+ } else {
1735
+ mainLines.push(line)
1736
+ }
1737
+ }
1738
+
1739
+ // Save last section
1740
+ if (currentSection) {
1741
+ // Preserve indentation: only remove leading/trailing blank lines, not spaces
1742
+ sections[currentSection] = trimBlankLines(currentContent.join('\n'))
1743
+ }
1744
+
1745
+ return {
1746
+ mainDescription: mainLines.join('\n').trim(),
1747
+ sections
1748
+ }
1749
+ }
1750
+
1751
+ /**
1752
+ * Remove leading and trailing blank lines while preserving line indentation
1753
+ * @param {string} text - Input text
1754
+ * @returns {string} Text with blank lines removed from start/end
1755
+ */
1756
+ function trimBlankLines(text) {
1757
+ if (!text) return ''
1758
+ // Split into lines
1759
+ const lines = text.split('\n')
1760
+ // Find first non-blank line
1761
+ let start = 0
1762
+ while (start < lines.length && /^\s*$/.test(lines[start])) {
1763
+ start++
1764
+ }
1765
+ // Find last non-blank line
1766
+ let end = lines.length - 1
1767
+ while (end >= start && /^\s*$/.test(lines[end])) {
1768
+ end--
1769
+ }
1770
+ // Return the slice, joined
1771
+ if (start > end) return ''
1772
+ return lines.slice(start, end + 1).join('\n')
1773
+ }
1774
+
1775
+ /**
1776
+ * Ensure description ends with a period
1777
+ * @param {string} desc - Description text
1778
+ * @returns {string} Description ending with period
1779
+ */
1780
+ function ensurePeriod(desc) {
1781
+ if (!desc) return ''
1782
+ const trimmed = desc.trim()
1783
+ if (!trimmed) return ''
1784
+
1785
+ // Handle multi-paragraph descriptions - ensure each paragraph ends with punctuation
1786
+ // Split on double newlines (paragraph breaks)
1787
+ const paragraphs = trimmed.split(/\n\s*\n/)
1788
+ const fixedParagraphs = paragraphs.map(p => {
1789
+ const trimmedP = p.trim()
1790
+ if (!trimmedP) return ''
1791
+ // Don't modify code blocks or their delimiters
1792
+ if (trimmedP.startsWith('----') || trimmedP.endsWith('----') || trimmedP.startsWith('[,')) {
1793
+ return trimmedP
1794
+ }
1795
+ // Don't modify lines that are clearly code or formatting (start with special chars)
1796
+ // Also skip list items (bullets and numbered lists)
1797
+ if (/^[|=\[\]*\-0-9]/.test(trimmedP)) {
1798
+ return trimmedP
1799
+ }
1800
+
1801
+ // Check if paragraph contains list items - if so, process line by line
1802
+ if (/\n\s*[-*]\s+/.test(trimmedP)) {
1803
+ const lines = trimmedP.split('\n')
1804
+ const fixedLines = lines.map(line => {
1805
+ const trimmedLine = line.trim()
1806
+ // Skip list items
1807
+ if (/^[-*]\s+/.test(trimmedLine)) {
1808
+ return line
1809
+ }
1810
+ // Skip empty lines
1811
+ if (!trimmedLine) {
1812
+ return line
1813
+ }
1814
+ // For non-list lines, add period if needed
1815
+ if (!/[.!?:;]$/.test(trimmedLine)) {
1816
+ return line + '.'
1817
+ }
1818
+ return line
1819
+ })
1820
+ return fixedLines.join('\n')
1821
+ }
1822
+
1823
+ // Add period if paragraph doesn't end with sentence-ending punctuation
1824
+ // Note: Colons indicate lists/code follow, so don't add period after them
1825
+ // Semicolons in lists also shouldn't get periods
1826
+ if (!/[.!?:;]$/.test(trimmedP)) {
1827
+ // Special case: if the entire paragraph is just a backticked command (starts and ends with `),
1828
+ // don't add a period - it's a command reference, not a sentence
1829
+ if (/^`[^`]+`$/.test(trimmedP)) {
1830
+ return trimmedP
1831
+ }
1832
+ return trimmedP + '.'
1833
+ }
1834
+ return trimmedP
1835
+ }).filter(p => p)
1836
+
1837
+ return fixedParagraphs.join('\n\n')
1838
+ }
1839
+
1840
+ /**
1841
+ * Convert command path to dashified filename
1842
+ * @param {string} commandPath - Command path (e.g., "rpk topic create")
1843
+ * @returns {string} Dashified path (e.g., "rpk-topic-create")
1844
+ */
1845
+ function dashify(commandPath) {
1846
+ return commandPath.replace(/\s+/g, '-')
1847
+ }
1848
+
1849
+ /**
1850
+ * Cap description to first two sentences
1851
+ * @param {string} desc - Full description
1852
+ * @returns {string} Short description
1853
+ */
1854
+ function capToTwoSentences(desc) {
1855
+ if (!desc) return ''
1856
+ if (typeof desc !== 'string') return String(desc)
1857
+
1858
+ // Remove section headers and content after them (for overrides that include full content)
1859
+ // Pattern: matches == Section Header and everything after it
1860
+ let cleaned = desc.replace(/\s*==\s+.+$/s, '')
1861
+
1862
+ // Remove indented content (tables, lists, etc.) after colons before sentence detection
1863
+ // Pattern: text ending with colon, optionally followed by blank line, then indented content
1864
+ // Examples:
1865
+ // "Retrieve license information:\n\n Org: description\n Type: description" (with blank line)
1866
+ // "Loggers are discovered by, in order:\n 1. First\n 2. Second" (no blank line)
1867
+ // Should both become: "Text ending with colon:"
1868
+ // The pattern matches: colon, optional whitespace/newlines, then indented content
1869
+ cleaned = cleaned.replace(/:\s*\n+[ \t]+.+$/s, ':')
1870
+
1871
+ // Normalize newlines to spaces for inline use (like :description: attribute)
1872
+ const singleLine = cleaned.replace(/\s*\n+\s*/g, ' ').trim()
1873
+
1874
+ // Protect backticked content first (prevents periods inside backticks from breaking sentences)
1875
+ const backticks = []
1876
+ let normalized = singleLine.replace(/`[^`]+`/g, match => {
1877
+ const ph = `__BACKTICK${backticks.length}__`
1878
+ backticks.push({ ph, original: match })
1879
+ return ph
1880
+ })
1881
+
1882
+ // Protect common abbreviations
1883
+ const abbrevs = ['e.g.', 'i.e.', 'etc.', 'vs.', 'v.']
1884
+ const placeholders = []
1885
+
1886
+ for (const abbrev of abbrevs) {
1887
+ const regex = new RegExp(abbrev.replace(/\./g, '\\.'), 'gi')
1888
+ normalized = normalized.replace(regex, match => {
1889
+ const ph = `__ABBREV${placeholders.length}__`
1890
+ placeholders.push({ ph, original: match })
1891
+ return ph
1892
+ })
1893
+ }
1894
+
1895
+ // Match sentences
1896
+ const sentences = normalized.match(/[^.!?]+[.!?]+(?:\s|$)/g)
1897
+ if (!sentences || sentences.length === 0) {
1898
+ // Restore and return
1899
+ let result = normalized
1900
+ placeholders.forEach(({ ph, original }) => {
1901
+ result = result.replace(ph, original)
1902
+ })
1903
+ backticks.forEach(({ ph, original }) => {
1904
+ result = result.replace(ph, original)
1905
+ })
1906
+ return result
1907
+ }
1908
+
1909
+ let result = sentences.slice(0, 2).join('')
1910
+
1911
+ // Restore abbreviations
1912
+ placeholders.forEach(({ ph, original }) => {
1913
+ result = result.replace(ph, original)
1914
+ })
1915
+
1916
+ // Restore backticked content
1917
+ backticks.forEach(({ ph, original }) => {
1918
+ result = result.replace(ph, original)
1919
+ })
1920
+
1921
+ return result.trim()
1922
+ }
1923
+
1924
+ /**
1925
+ * Flatten command tree into array of commands with full paths
1926
+ * @param {Object} node - Tree node
1927
+ * @param {string} parentPath - Parent command path
1928
+ * @returns {Array} Array of { path, command } objects
1929
+ */
1930
+ function flattenCommands(node, parentPath = '') {
1931
+ const results = []
1932
+
1933
+ const currentPath = parentPath ? `${parentPath} ${node.name}` : node.name
1934
+
1935
+ results.push({
1936
+ path: currentPath,
1937
+ command: node
1938
+ })
1939
+
1940
+ if (node.commands && Array.isArray(node.commands)) {
1941
+ for (const child of node.commands) {
1942
+ results.push(...flattenCommands(child, currentPath))
1943
+ }
1944
+ }
1945
+
1946
+ return results
1947
+ }
1948
+
1949
+ /**
1950
+ * Determine output path for a command based on directory structure rules:
1951
+ * - Top-level commands with subcommands go in subdirectories (e.g., rpk-topic/rpk-topic.adoc)
1952
+ * - Descendants of those commands go in the same subdirectory (e.g., rpk-topic/rpk-topic-create.adoc)
1953
+ * - Top-level commands without subcommands go at root (e.g., rpk-help.adoc)
1954
+ *
1955
+ * @param {string} commandPath - Full command path (e.g., "rpk topic create")
1956
+ * @param {Set<string>} topLevelWithSubcommands - Set of top-level command names that have subcommands
1957
+ * @returns {Object} { subdir: string|null, fileName: string }
1958
+ */
1959
+ function getOutputPath(commandPath, topLevelWithSubcommands) {
1960
+ const parts = commandPath.split(' ')
1961
+ const fileName = `${dashify(commandPath)}.adoc`
1962
+
1963
+ // Root command (rpk itself)
1964
+ if (parts.length === 1) {
1965
+ return { subdir: null, fileName }
1966
+ }
1967
+
1968
+ // Get the top-level command name (first part after 'rpk')
1969
+ const topLevelName = parts[1]
1970
+
1971
+ // If this top-level command has subcommands, use subdirectory
1972
+ if (topLevelWithSubcommands.has(topLevelName)) {
1973
+ const subdir = `rpk-${topLevelName}`
1974
+ return { subdir, fileName }
1975
+ }
1976
+
1977
+ // Otherwise, file goes at root
1978
+ return { subdir: null, fileName }
1979
+ }
1980
+
1981
+ /**
1982
+ * Identify top-level commands (direct children of rpk) that have subcommands
1983
+ * @param {Object} tree - The rpk command tree
1984
+ * @returns {Set<string>} Set of command names that have subcommands
1985
+ */
1986
+ function findTopLevelWithSubcommands(tree) {
1987
+ const result = new Set()
1988
+
1989
+ if (tree.commands && Array.isArray(tree.commands)) {
1990
+ for (const child of tree.commands) {
1991
+ if (child.commands && child.commands.length > 0) {
1992
+ result.add(child.name)
1993
+ }
1994
+ }
1995
+ }
1996
+
1997
+ return result
1998
+ }
1999
+
2000
+ /**
2001
+ * Generate AsciiDoc documentation for all rpk commands
2002
+ * @param {Object} options - Generation options
2003
+ * @returns {Object} Generation result
2004
+ */
2005
+ async function generateRpkDocs(options = {}) {
2006
+ const {
2007
+ tree,
2008
+ overrides,
2009
+ outputDir,
2010
+ cloudSecretDir, // Directory for rpk cloud and rpk security secret commands
2011
+ rpkVersion,
2012
+ pluginVersions = {},
2013
+ draftMissing = false,
2014
+ flatOutput = false // If true, output all files flat (legacy behavior)
2015
+ } = options
2016
+
2017
+ // Register partials
2018
+ const partialsDir = path.join(TEMPLATES_DIR)
2019
+ const partialFiles = fs.readdirSync(partialsDir).filter(f => f.endsWith('.hbs') && f !== 'command.hbs')
2020
+ for (const file of partialFiles) {
2021
+ const name = file.replace('.hbs', '')
2022
+ registerPartial(name, path.join(partialsDir, file))
2023
+ }
2024
+
2025
+ // Load main template
2026
+ const templatePath = path.join(TEMPLATES_DIR, 'command.hbs')
2027
+ const template = loadTemplate(templatePath)
2028
+
2029
+ // Resolve references in overrides
2030
+ const resolvedOverrides = overrides ? resolveReferences(overrides, overrides) : null
2031
+
2032
+ // Extract text transformations from overrides (applied globally to all descriptions)
2033
+ const textTransformations = resolvedOverrides?.textTransformations || null
2034
+ if (textTransformations) {
2035
+ const replacementCount = textTransformations.replacements?.length || 0
2036
+ const inlineCodeCount = textTransformations.inlineCode?.length || 0
2037
+ if (replacementCount > 0 || inlineCodeCount > 0) {
2038
+ console.log(`Loaded text transformations: ${replacementCount} replacement(s), ${inlineCodeCount} inline code pattern(s)`)
2039
+ }
2040
+ }
2041
+
2042
+ // Create output directory
2043
+ try {
2044
+ fs.mkdirSync(outputDir, { recursive: true })
2045
+ } catch (err) {
2046
+ throw new Error(`Failed to create output directory "${outputDir}": ${err.message}`)
2047
+ }
2048
+
2049
+ // Flatten command tree
2050
+ const commands = flattenCommands(tree)
2051
+
2052
+ // Find top-level commands with subcommands (for directory structure)
2053
+ const topLevelWithSubcommands = findTopLevelWithSubcommands(tree)
2054
+
2055
+ // Track created subdirectories
2056
+ const createdSubdirs = new Set()
2057
+
2058
+ let filesGenerated = 0
2059
+ let filesSkipped = 0
2060
+ let filesFailed = 0
2061
+
2062
+ for (const { path: commandPath, command } of commands) {
2063
+ // Check if command should be excluded
2064
+ if (shouldExcludeCommand(resolvedOverrides, commandPath)) {
2065
+ filesSkipped++
2066
+ continue
2067
+ }
2068
+
2069
+ // Merge overrides
2070
+ const mergedCommand = mergeCommandOverrides(command, resolvedOverrides, commandPath)
2071
+
2072
+ // Get preserved content for this command
2073
+ const commandOverride = resolvedOverrides?.commands?.[commandPath] || {}
2074
+ // Parse description sections
2075
+ const { mainDescription, sections: rawSections } = parseDescriptionSections(mergedCommand.description || '')
2076
+
2077
+ // Format description with backticks and ensure it ends with period
2078
+ const formattedDescription = ensurePeriod(formatDescription(mainDescription, textTransformations))
2079
+
2080
+ // Format section content with same formatting rules
2081
+ // BUT: Handle EXAMPLES sections specially to convert indented commands to code blocks
2082
+ const sections = {}
2083
+ for (const [sectionName, sectionContent] of Object.entries(rawSections)) {
2084
+ if (sectionName === 'EXAMPLES') {
2085
+ // Apply excludeExamples filter BEFORE formatting
2086
+ let examplesContent = sectionContent
2087
+ if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2088
+ examplesContent = filterExamples(sectionContent, mergedCommand.excludeExamples)
2089
+ }
2090
+ // Format examples: convert indented commands to code blocks
2091
+ sections[sectionName] = formatExamples(examplesContent)
2092
+ } else {
2093
+ sections[sectionName] = formatDescription(sectionContent, textTransformations)
2094
+ }
2095
+ }
2096
+
2097
+ // Add examples from rpk source if not already in sections
2098
+ // rpk --print-tree provides examples in a separate field
2099
+ if (command.examples && !sections.EXAMPLES) {
2100
+ let examplesContent = command.examples.trim()
2101
+ // Apply excludeExamples filter BEFORE formatting
2102
+ if (mergedCommand.excludeExamples && mergedCommand.excludeExamples.length > 0) {
2103
+ examplesContent = filterExamples(examplesContent, mergedCommand.excludeExamples)
2104
+ }
2105
+ sections.EXAMPLES = formatExamples(examplesContent)
2106
+ }
2107
+
2108
+ // Process unified content array from overrides
2109
+ const processedContent = processContentArray(mergedCommand.content, commandPath)
2110
+
2111
+ // Process section overrides: exclude or replace content
2112
+ // Check content array for section items that modify source sections
2113
+ if (mergedCommand.content && Array.isArray(mergedCommand.content)) {
2114
+ for (const item of mergedCommand.content) {
2115
+ if (item.type === 'section' && item.id) {
2116
+ // Try both lowercase and uppercase versions of the ID
2117
+ // (section IDs in the description are ALL CAPS, but override IDs might be lowercase)
2118
+ const uppercaseId = item.id.toUpperCase()
2119
+ const lowercaseId = item.id.toLowerCase()
2120
+ const titleCaseId = item.id.charAt(0).toUpperCase() + item.id.slice(1).toLowerCase()
2121
+
2122
+ // Find which key matches a source section
2123
+ const matchingKey = sections[uppercaseId] ? uppercaseId :
2124
+ sections[lowercaseId] ? lowercaseId :
2125
+ sections[titleCaseId] ? titleCaseId :
2126
+ sections[item.id] ? item.id : null
2127
+
2128
+ if (matchingKey) {
2129
+ if (item.exclude === true) {
2130
+ // Exclude: remove the section entirely
2131
+ delete sections[matchingKey]
2132
+ } else if (item.content !== undefined) {
2133
+ // Replace: use override content instead of source content
2134
+ sections[matchingKey] = item.content
2135
+ }
2136
+ }
2137
+ }
2138
+ }
2139
+ }
2140
+
2141
+ // If there's a section with id='examples' OR type='examples'/'example' in content array, suppress sections.EXAMPLES
2142
+ // to avoid duplicate Examples sections in the output
2143
+ const hasExamplesSection = processedContent.sections &&
2144
+ Object.values(processedContent.sections).some(arr =>
2145
+ arr.some(s => s.id === 'examples' || s.type === 'examples' || s.type === 'example')
2146
+ )
2147
+
2148
+ if (hasExamplesSection) {
2149
+ delete sections.EXAMPLES
2150
+ }
2151
+
2152
+ // Compute unknown sections (sections not explicitly handled by template)
2153
+ // These will be rendered in a generic loop at the end
2154
+ const knownSections = new Set([
2155
+ 'COMMON FILES', 'BARE-METAL', 'KUBERNETES', 'EXTRA REQUESTS FOR PARTITIONS',
2156
+ 'FORMAT', 'COLORS', 'MODIFIERS', 'RAW MODE',
2157
+ 'PRINCIPALS', 'ROLES', 'HOSTS', 'RESOURCES', 'OPERATIONS', 'PERMISSIONS', 'MANAGEMENT',
2158
+ 'NUMBERS', 'TEXT', 'HEADERS', 'ATTRIBUTES', 'OFFSETS', 'VALUES',
2159
+ 'SCHEMA-REGISTRY', 'TOMBSTONES', 'EXAMPLES', 'MISC', 'NOTES'
2160
+ ])
2161
+ const unknownSections = Object.entries(sections)
2162
+ .filter(([name]) => !knownSections.has(name))
2163
+ .map(([name, content]) => ({ name, content }))
2164
+
2165
+ // Determine plugin info
2166
+ const pathParts = commandPath.split(' ')
2167
+ let pluginName = null
2168
+ let pluginVersion = null
2169
+ if (pathParts.length >= 2 && pluginVersions[pathParts[1]]) {
2170
+ pluginName = pathParts[1]
2171
+ pluginVersion = pluginVersions[pathParts[1]]
2172
+ }
2173
+
2174
+ // Build subcommands with correct xref paths
2175
+ // Filter out excluded subcommands so they don't appear in the parent's list
2176
+ const subcommands = (command.commands || [])
2177
+ .filter(sub => {
2178
+ const subPath = `${commandPath} ${sub.name}`
2179
+ return !shouldExcludeCommand(resolvedOverrides, subPath)
2180
+ })
2181
+ .map(sub => {
2182
+ const subPath = `${commandPath} ${sub.name}`
2183
+ const dashifiedSubPath = dashify(subPath)
2184
+
2185
+ // Determine xref path based on directory structure
2186
+ let xrefPath
2187
+ if (flatOutput) {
2188
+ xrefPath = `reference:rpk/${dashifiedSubPath}.adoc`
2189
+ } else {
2190
+ // For structured output, figure out the correct path
2191
+ const subParts = subPath.split(' ')
2192
+ const subTopLevel = subParts.length >= 2 ? subParts[1] : null
2193
+ if (subTopLevel && topLevelWithSubcommands.has(subTopLevel)) {
2194
+ xrefPath = `reference:rpk/rpk-${subTopLevel}/${dashifiedSubPath}.adoc`
2195
+ } else {
2196
+ xrefPath = `reference:rpk/${dashifiedSubPath}.adoc`
2197
+ }
2198
+ }
2199
+
2200
+ // Use overridden description if available
2201
+ const subOverride = resolvedOverrides?.commands?.[subPath]
2202
+ const subDescription = subOverride?.description || sub.description || ''
2203
+
2204
+ return {
2205
+ ...sub,
2206
+ fullPath: subPath,
2207
+ dashifiedPath: dashifiedSubPath,
2208
+ xrefPath,
2209
+ shortDesc: ensurePeriod(capToTwoSentences(formatDescription(subDescription, textTransformations, { skipTableConversion: true, skipListConversion: true })))
2210
+ }
2211
+ })
2212
+
2213
+ // Determine platform-specific info
2214
+ // Only show platform info if the tree was actually merged from multiple platforms
2215
+ const platforms = command.platforms || []
2216
+ const platformsMerged = tree.platforms_merged || []
2217
+ const hasPlatformData = platformsMerged.length >= 2 // Only meaningful if we have data from 2+ platforms
2218
+ const isLinuxOnly = hasPlatformData && platforms.length === 1 && platforms.includes('linux')
2219
+ const isDarwinOnly = hasPlatformData && platforms.length === 1 && platforms.includes('darwin')
2220
+ const pagePlatforms = isLinuxOnly ? 'linux' : isDarwinOnly ? 'darwin' : null
2221
+
2222
+ // Content is now processed via processedContent from the unified content array
2223
+ // customSectionsByPosition comes from processedContent.sections
2224
+
2225
+ // Build template context
2226
+ const context = {
2227
+ commandPath,
2228
+ dashifiedPath: dashify(commandPath),
2229
+ name: command.name,
2230
+ description: formattedDescription,
2231
+ shortDesc: ensurePeriod(capToTwoSentences(formatDescription(mainDescription, textTransformations, { skipTableConversion: true, skipListConversion: true }))),
2232
+ usage: formatUsage(command.usage),
2233
+ aliases: (mergedCommand.aliases || command.aliases || [])
2234
+ .filter(alias => !commandOverride.excludeAliases?.includes(alias))
2235
+ .map(alias => {
2236
+ // Prepend parent command path to alias
2237
+ // e.g., "rpk cluster partitions list" with alias "ls" → "rpk cluster partitions ls"
2238
+ const parentPath = commandPath.split(' ').slice(0, -1).join(' ')
2239
+ return `${parentPath} ${alias}`
2240
+ }),
2241
+ aliasNotes: commandOverride.aliasNotes,
2242
+ flags: (mergedCommand.flags || []).map(flag => ({
2243
+ ...flag,
2244
+ name: flag.shorthand ? `-${flag.shorthand}, --${flag.name}` : `--${flag.name}`,
2245
+ type: flag.type || '-',
2246
+ description: ensurePeriod(formatDescription(flag.description || '', textTransformations)),
2247
+ deprecated: flag.deprecated,
2248
+ deprecatedMessage: flag.deprecatedMessage,
2249
+ cloudOnly: flag.cloudOnly,
2250
+ selfHostedOnly: flag.selfHostedOnly,
2251
+ validValues: flag.validValues,
2252
+ introducedInVersion: flag.introducedInVersion
2253
+ })),
2254
+ // Global flags come from root tree, not individual commands
2255
+ // Keep raw name/shorthand - template handles formatting
2256
+ globalFlags: (tree.global_flags || []).map(flag => ({
2257
+ ...flag,
2258
+ type: flag.type || '-',
2259
+ description: ensurePeriod(formatDescription(flag.description || '', textTransformations))
2260
+ })),
2261
+ subcommands,
2262
+ hasSubcommands: subcommands.length > 0,
2263
+ sections,
2264
+ rpkVersion,
2265
+ pluginName,
2266
+ pluginVersion,
2267
+ introducedInVersion: mergedCommand.introducedInVersion,
2268
+ platforms: mergedCommand.platforms || platforms,
2269
+ pagePlatforms: mergedCommand.platforms ? mergedCommand.platforms.join(',') : pagePlatforms,
2270
+ // Custom page attributes (e.g., page-topic-type, learning-objective-*)
2271
+ pageAttributes: commandOverride.pageAttributes || {},
2272
+ // Page aliases for backwards compatibility
2273
+ pageAliases: commandOverride.pageAliases,
2274
+ isLinuxOnly: mergedCommand.platforms
2275
+ ? mergedCommand.platforms.length === 1 && mergedCommand.platforms[0] === 'linux'
2276
+ : isLinuxOnly,
2277
+ isDarwinOnly: mergedCommand.platforms
2278
+ ? mergedCommand.platforms.length === 1 && mergedCommand.platforms[0] === 'darwin'
2279
+ : isDarwinOnly,
2280
+ // Deprecation info
2281
+ deprecated: mergedCommand.deprecated,
2282
+ deprecatedMessage: mergedCommand.deprecatedMessage,
2283
+ deprecatedInVersion: mergedCommand.deprecatedInVersion,
2284
+ removedInVersion: mergedCommand.removedInVersion,
2285
+ minVersion: mergedCommand.minVersion,
2286
+ // Cloud/self-hosted only
2287
+ cloudOnly: mergedCommand.cloudOnly,
2288
+ selfHostedOnly: mergedCommand.selfHostedOnly,
2289
+ // Description scope (for conditional description rendering)
2290
+ descriptionScope: mergedCommand.descriptionScope,
2291
+ // Prerequisites
2292
+ prerequisites: mergedCommand.prerequisites,
2293
+ hasPrerequisites: mergedCommand.prerequisites && mergedCommand.prerequisites.length > 0,
2294
+ // See also (additional links beyond subcommands) - pass through without transformations
2295
+ // (seeAlso items are already properly formatted by writers)
2296
+ seeAlso: mergedCommand.seeAlso,
2297
+ hasSeeAlso: mergedCommand.seeAlso && mergedCommand.seeAlso.length > 0,
2298
+ // Custom sections from unified content array
2299
+ customSectionsByPosition: processedContent.sections,
2300
+ // Admonitions from unified content array
2301
+ admonitions: {
2302
+ afterHeader: processedContent.admonitions.after_header,
2303
+ afterDescription: processedContent.admonitions.after_description,
2304
+ afterUsage: processedContent.admonitions.after_usage,
2305
+ afterAliases: processedContent.admonitions.after_aliases,
2306
+ afterFlags: processedContent.admonitions.after_flags,
2307
+ afterModifiers: processedContent.admonitions.after_modifiers,
2308
+ afterExamples: processedContent.admonitions.after_examples,
2309
+ beforeSeeAlso: processedContent.admonitions.before_see_also,
2310
+ end: processedContent.admonitions.end
2311
+ },
2312
+ // Cloud-only content from unified content array
2313
+ cloudContent: {
2314
+ afterHeader: processedContent.cloudContent.after_header,
2315
+ afterDescription: processedContent.cloudContent.after_description,
2316
+ afterUsage: processedContent.cloudContent.after_usage,
2317
+ afterAliases: processedContent.cloudContent.after_aliases,
2318
+ afterFlags: processedContent.cloudContent.after_flags,
2319
+ afterModifiers: processedContent.cloudContent.after_modifiers,
2320
+ afterExamples: processedContent.cloudContent.after_examples,
2321
+ beforeSeeAlso: processedContent.cloudContent.before_see_also,
2322
+ end: processedContent.cloudContent.end
2323
+ },
2324
+ // Self-hosted content from unified content array
2325
+ selfHostedContent: {
2326
+ afterHeader: processedContent.selfHostedContent.after_header,
2327
+ afterDescription: processedContent.selfHostedContent.after_description,
2328
+ afterUsage: processedContent.selfHostedContent.after_usage,
2329
+ afterAliases: processedContent.selfHostedContent.after_aliases,
2330
+ afterFlags: processedContent.selfHostedContent.after_flags,
2331
+ afterModifiers: processedContent.selfHostedContent.after_modifiers,
2332
+ afterExamples: processedContent.selfHostedContent.after_examples,
2333
+ beforeSeeAlso: processedContent.selfHostedContent.before_see_also,
2334
+ end: processedContent.selfHostedContent.end
2335
+ },
2336
+ // Includes from unified content array
2337
+ includes: {
2338
+ afterHeader: processedContent.includes.after_header,
2339
+ afterDescription: processedContent.includes.after_description,
2340
+ afterUsage: processedContent.includes.after_usage,
2341
+ afterAliases: processedContent.includes.after_aliases,
2342
+ afterFlags: processedContent.includes.after_flags,
2343
+ afterModifiers: processedContent.includes.after_modifiers,
2344
+ afterExamples: processedContent.includes.after_examples,
2345
+ beforeSeeAlso: processedContent.includes.before_see_also,
2346
+ end: processedContent.includes.end
2347
+ },
2348
+ // Unknown sections from rpk source not explicitly handled by template
2349
+ unknownSections,
2350
+ hasUnknownSections: unknownSections.length > 0
2351
+ }
2352
+
2353
+ // Render template
2354
+ const content = template(context)
2355
+
2356
+ // Determine output path
2357
+ // Check if this command should go to cloudSecretDir
2358
+ const isCloudCommand = commandPath.startsWith('rpk cloud')
2359
+ const isSecuritySecretCommand = commandPath.startsWith('rpk security secret')
2360
+ const useCloudSecretDir = cloudSecretDir && (isCloudCommand || isSecuritySecretCommand)
2361
+ const effectiveOutputDir = useCloudSecretDir ? cloudSecretDir : outputDir
2362
+
2363
+ let filePath
2364
+ if (flatOutput) {
2365
+ // Legacy flat output: all files in one directory
2366
+ const fileName = `${dashify(commandPath)}.adoc`
2367
+ filePath = path.join(effectiveOutputDir, fileName)
2368
+ } else {
2369
+ // Structured output: subdirectories for commands with subcommands
2370
+ const { subdir, fileName } = getOutputPath(commandPath, topLevelWithSubcommands)
2371
+
2372
+ if (subdir) {
2373
+ const subdirPath = path.join(effectiveOutputDir, subdir)
2374
+ // Track full path to handle multiple output directories
2375
+ if (!createdSubdirs.has(subdirPath)) {
2376
+ fs.mkdirSync(subdirPath, { recursive: true })
2377
+ createdSubdirs.add(subdirPath)
2378
+ }
2379
+ filePath = path.join(subdirPath, fileName)
2380
+ } else {
2381
+ filePath = path.join(effectiveOutputDir, fileName)
2382
+ }
2383
+ }
2384
+
2385
+ try {
2386
+ fs.writeFileSync(filePath, content, 'utf8')
2387
+ filesGenerated++
2388
+
2389
+ if (filesGenerated % 50 === 0) {
2390
+ console.log(` Generated ${filesGenerated} files...`)
2391
+ }
2392
+ } catch (err) {
2393
+ console.error(`ERROR: Failed to write "${filePath}": ${err.message}`)
2394
+ filesFailed++
2395
+ }
2396
+ }
2397
+
2398
+ return {
2399
+ commandCount: commands.length,
2400
+ filesGenerated,
2401
+ filesSkipped,
2402
+ filesFailed,
2403
+ subdirectoriesCreated: createdSubdirs.size
2404
+ }
2405
+ }
2406
+
2407
+ module.exports = {
2408
+ generateRpkDocs,
2409
+ mergeCommandOverrides,
2410
+ applyOverridesToTree,
2411
+ resolveReferences,
2412
+ deepMerge,
2413
+ formatDescription,
2414
+ convertNumberedListsToAsciiDoc,
2415
+ convertIndentedTablesToAsciiDoc,
2416
+ convertIndentedCodeBlocksToAsciiDoc,
2417
+ decodeHtmlEntities,
2418
+ parseDescriptionSections,
2419
+ ensurePeriod,
2420
+ dashify,
2421
+ capToTwoSentences,
2422
+ flattenCommands,
2423
+ getOutputPath,
2424
+ findTopLevelWithSubcommands,
2425
+ loadTemplate,
2426
+ registerPartial,
2427
+ shouldExcludeCommand,
2428
+ getCommandMetadata,
2429
+ processContentArray,
2430
+ // Exported for testing
2431
+ filterExamples,
2432
+ formatExamples
2433
+ }