@redpanda-data/docs-extensions-and-macros 5.0.4 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/doc-tools-mcp.js +30 -2
- package/bin/doc-tools.js +260 -51
- package/bin/mcp-tools/rpk-docs.js +109 -39
- package/extensions/algolia-indexer/generate-index.js +81 -26
- package/extensions/version-fetcher/set-latest-version.js +0 -131
- package/package.json +2 -2
- package/tools/rpk-docs/DEPRECATION-SCANNING.md +192 -0
- package/tools/rpk-docs/extract-conditional-content.js +169 -0
- package/tools/rpk-docs/extract-overrides.js +566 -0
- package/tools/rpk-docs/generate-rpk-docs.js +2433 -0
- package/tools/rpk-docs/helpers/index.js +458 -0
- package/tools/rpk-docs/report-delta.js +479 -0
- package/tools/rpk-docs/rpk-docs-handler.js +1650 -0
- package/tools/rpk-docs/scan-deprecated-commands.js +295 -0
- package/tools/rpk-docs/templates/command.hbs +576 -0
- package/tools/rpk-docs/templates/content-item.hbs +106 -0
- package/tools/rpk-docs/templates/subsection.hbs +37 -0
- package/tools/rpk-docs/validate-output.js +414 -0
- package/tools/rpk-docs/validate-overrides.js +624 -0
|
@@ -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(/=/g, '=')
|
|
722
|
+
.replace(/'/g, "'")
|
|
723
|
+
.replace(/`/g, '`')
|
|
724
|
+
.replace(/"/g, '"')
|
|
725
|
+
.replace(/&/g, '&')
|
|
726
|
+
.replace(/</g, '<')
|
|
727
|
+
.replace(/>/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
|
+
}
|