@redpanda-data/docs-extensions-and-macros 5.0.4 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,566 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+
6
+ /**
7
+ * Extract custom content from existing rpk docs and generate override suggestions
8
+ *
9
+ * This tool scans existing rpk documentation files, identifies editorial content
10
+ * that would be lost during auto-generation, and produces override entries
11
+ * that preserve this content.
12
+ */
13
+
14
+ /**
15
+ * Parse an AsciiDoc file and extract its structure
16
+ * @param {string} content - File content
17
+ * @returns {Object} Parsed structure
18
+ */
19
+ function parseAsciiDoc(content) {
20
+ const result = {
21
+ title: '',
22
+ attributes: {},
23
+ description: '',
24
+ includesAfterHeader: [],
25
+ includesAfterDescription: [],
26
+ usageSection: '',
27
+ flagsSection: '',
28
+ customSections: [],
29
+ conditionalBlocks: [],
30
+ contentAfterFlags: '',
31
+ seeAlso: [],
32
+ rawContent: content
33
+ }
34
+
35
+ const lines = content.split('\n')
36
+ let currentSection = 'header'
37
+ let sectionContent = []
38
+ let inConditional = null
39
+ let conditionalContent = []
40
+ let conditionalDepth = 0
41
+
42
+ for (let i = 0; i < lines.length; i++) {
43
+ const line = lines[i]
44
+ const trimmedLine = line.trim()
45
+
46
+ // Extract title
47
+ if (trimmedLine.startsWith('= ') && !result.title) {
48
+ result.title = trimmedLine.substring(2).trim()
49
+ continue
50
+ }
51
+
52
+ // Extract attributes
53
+ if (trimmedLine.startsWith(':') && trimmedLine.includes(':') && currentSection === 'header') {
54
+ const match = trimmedLine.match(/^:([^:]+):\s*(.*)$/)
55
+ if (match) {
56
+ result.attributes[match[1]] = match[2]
57
+ }
58
+ continue
59
+ }
60
+
61
+ // Track conditional blocks (ifdef/ifndef)
62
+ if (trimmedLine.startsWith('ifdef::') || trimmedLine.startsWith('ifndef::')) {
63
+ if (conditionalDepth === 0) {
64
+ inConditional = {
65
+ type: trimmedLine.startsWith('ifdef::') ? 'ifdef' : 'ifndef',
66
+ condition: trimmedLine.match(/::(.*)\[\]/)?.[1] || '',
67
+ content: [],
68
+ startLine: i
69
+ }
70
+ }
71
+ conditionalDepth++
72
+ if (conditionalDepth > 1) {
73
+ inConditional.content.push(line)
74
+ }
75
+ continue
76
+ }
77
+
78
+ if (trimmedLine === 'endif::[]') {
79
+ conditionalDepth--
80
+ if (conditionalDepth === 0 && inConditional) {
81
+ inConditional.endLine = i
82
+ result.conditionalBlocks.push(inConditional)
83
+ inConditional = null
84
+ } else if (conditionalDepth > 0 && inConditional) {
85
+ inConditional.content.push(line)
86
+ }
87
+ continue
88
+ }
89
+
90
+ if (inConditional && conditionalDepth > 0) {
91
+ inConditional.content.push(line)
92
+ continue
93
+ }
94
+
95
+ // Extract includes
96
+ if (trimmedLine.startsWith('include::')) {
97
+ const includePath = trimmedLine.match(/include::([^\[]+)/)?.[1]
98
+ if (includePath) {
99
+ if (currentSection === 'header' || currentSection === 'description') {
100
+ result.includesAfterHeader.push(includePath)
101
+ } else {
102
+ result.includesAfterDescription.push(includePath)
103
+ }
104
+ }
105
+ continue
106
+ }
107
+
108
+ // Detect section transitions
109
+ if (trimmedLine === '== Usage') {
110
+ if (currentSection === 'header' || currentSection === 'description') {
111
+ result.description = sectionContent.join('\n').trim()
112
+ }
113
+ currentSection = 'usage'
114
+ sectionContent = []
115
+ continue
116
+ }
117
+
118
+ if (trimmedLine === '== Flags') {
119
+ if (currentSection === 'usage') {
120
+ result.usageSection = sectionContent.join('\n').trim()
121
+ }
122
+ currentSection = 'flags'
123
+ sectionContent = []
124
+ continue
125
+ }
126
+
127
+ if (trimmedLine === '== Global flags') {
128
+ if (currentSection === 'flags') {
129
+ result.flagsSection = sectionContent.join('\n').trim()
130
+ }
131
+ currentSection = 'globalflags'
132
+ sectionContent = []
133
+ continue
134
+ }
135
+
136
+ // Detect custom sections (== or === headers after flags)
137
+ if ((trimmedLine.startsWith('== ') || trimmedLine.startsWith('=== ')) &&
138
+ (currentSection === 'flags' || currentSection === 'globalflags' || currentSection === 'custom')) {
139
+
140
+ // Save previous section
141
+ if (currentSection === 'flags') {
142
+ result.flagsSection = sectionContent.join('\n').trim()
143
+ } else if (sectionContent.length > 0) {
144
+ const lastCustom = result.customSections[result.customSections.length - 1]
145
+ if (lastCustom) {
146
+ lastCustom.content = sectionContent.join('\n').trim()
147
+ }
148
+ }
149
+
150
+ // Start new custom section
151
+ const level = trimmedLine.startsWith('=== ') ? 3 : 2
152
+ const title = trimmedLine.substring(level + 1).trim()
153
+
154
+ // Check if it's a "See also" or "Related topics" section
155
+ if (title.toLowerCase() === 'see also' || title.toLowerCase() === 'related topics') {
156
+ currentSection = 'seealso'
157
+ } else {
158
+ result.customSections.push({
159
+ title,
160
+ level,
161
+ content: ''
162
+ })
163
+ currentSection = 'custom'
164
+ }
165
+ sectionContent = []
166
+ continue
167
+ }
168
+
169
+ // Collect content
170
+ sectionContent.push(line)
171
+ }
172
+
173
+ // Handle remaining content
174
+ if (currentSection === 'seealso') {
175
+ // Extract see also links
176
+ const seeAlsoContent = sectionContent.join('\n')
177
+ const xrefMatches = seeAlsoContent.matchAll(/xref:([^\[]+)\[([^\]]*)\]/g)
178
+ for (const match of xrefMatches) {
179
+ result.seeAlso.push(`xref:${match[1]}[${match[2]}]`)
180
+ }
181
+ } else if (currentSection === 'custom' && result.customSections.length > 0) {
182
+ result.customSections[result.customSections.length - 1].content = sectionContent.join('\n').trim()
183
+ } else if (currentSection === 'flags') {
184
+ result.flagsSection = sectionContent.join('\n').trim()
185
+ }
186
+
187
+ return result
188
+ }
189
+
190
+ /**
191
+ * Determine the command path from a file path
192
+ * @param {string} filePath - Path to the .adoc file
193
+ * @param {string} baseDir - Base rpk docs directory
194
+ * @returns {string} Command path (e.g., "rpk topic create")
195
+ */
196
+ function filePathToCommandPath(filePath, baseDir) {
197
+ const relativePath = path.relative(baseDir, filePath)
198
+ const withoutExt = relativePath.replace(/\.adoc$/, '')
199
+
200
+ // Convert path separators and dashes to spaces
201
+ // e.g., "rpk-topic/rpk-topic-create" -> "rpk topic create"
202
+ const parts = withoutExt.split(path.sep)
203
+ const lastPart = parts[parts.length - 1]
204
+
205
+ return lastPart.replace(/-/g, ' ')
206
+ }
207
+
208
+ /**
209
+ * Clean up extracted content by removing markers and noise
210
+ * @param {string} content
211
+ * @returns {string}
212
+ */
213
+ function cleanContent(content) {
214
+ if (!content) return ''
215
+ return content
216
+ // Remove single-source tags
217
+ .replace(/\/\/\s*tag::single-source\[\]\s*/g, '')
218
+ .replace(/\/\/\s*end::single-source\[\]\s*/g, '')
219
+ // Remove trailing endif markers that got captured
220
+ .replace(/\s*endif::\[\]\s*$/g, '')
221
+ // Clean up multiple blank lines
222
+ .replace(/\n{3,}/g, '\n\n')
223
+ .trim()
224
+ }
225
+
226
+ /**
227
+ * Check if content is meaningful (not just whitespace or comments)
228
+ * @param {string} content
229
+ * @returns {boolean}
230
+ */
231
+ function isMeaningfulContent(content) {
232
+ if (!content) return false
233
+ const cleaned = cleanContent(content)
234
+ if (!cleaned) return false
235
+ return true
236
+ }
237
+
238
+ /**
239
+ * Extract override suggestions from a parsed document
240
+ * @param {Object} parsed - Parsed AsciiDoc structure
241
+ * @param {string} commandPath - Command path
242
+ * @returns {Object|null} Override suggestion or null if no custom content
243
+ */
244
+ function extractOverrideSuggestion(parsed, commandPath) {
245
+ const override = {}
246
+ let hasCustomContent = false
247
+
248
+ // Check for custom description (more than just the auto-generated short desc)
249
+ if (parsed.description && parsed.description.length > 100) {
250
+ // Check if description has multiple paragraphs or detailed content
251
+ const paragraphs = parsed.description.split(/\n\n+/).filter(p => p.trim())
252
+ if (paragraphs.length > 1) {
253
+ override.appendToDescription = cleanContent(paragraphs.slice(1).join('\n\n'))
254
+ hasCustomContent = true
255
+ }
256
+ }
257
+
258
+ // Check for includes
259
+ if (parsed.includesAfterHeader.length > 0) {
260
+ override.includes = override.includes || {}
261
+ override.includes.after_header = parsed.includesAfterHeader
262
+ hasCustomContent = true
263
+ }
264
+
265
+ // Check for unsupported-os attribute - convert to a note
266
+ if (parsed.attributes['unsupported-os']) {
267
+ const unsupportedOs = parsed.attributes['unsupported-os']
268
+ override.notes = override.notes || {}
269
+ override.notes.after_header = `This command is not supported on ${unsupportedOs}.`
270
+ hasCustomContent = true
271
+ }
272
+
273
+ // Check for custom sections
274
+ if (parsed.customSections.length > 0) {
275
+ override.customSections = {}
276
+ for (const section of parsed.customSections) {
277
+ const cleanedContent = cleanContent(section.content)
278
+ if (isMeaningfulContent(cleanedContent)) {
279
+ const key = section.title.toLowerCase().replace(/\s+/g, '-')
280
+ override.customSections[key] = {
281
+ title: section.title,
282
+ content: cleanedContent,
283
+ position: 'after_flags'
284
+ }
285
+ hasCustomContent = true
286
+ }
287
+ }
288
+ if (Object.keys(override.customSections).length === 0) {
289
+ delete override.customSections
290
+ }
291
+ }
292
+
293
+ // Check for see also links
294
+ if (parsed.seeAlso.length > 0) {
295
+ override.seeAlso = parsed.seeAlso
296
+ hasCustomContent = true
297
+ }
298
+
299
+ // Check for conditional blocks (cloud/self-hosted specific content)
300
+ for (const block of parsed.conditionalBlocks) {
301
+ const content = cleanContent(block.content.join('\n'))
302
+ if (isMeaningfulContent(content)) {
303
+ if (block.type === 'ifdef' && block.condition === 'env-cloud') {
304
+ override.cloudContent = override.cloudContent || {}
305
+ override.cloudContent.after_flags = content
306
+ hasCustomContent = true
307
+ } else if (block.type === 'ifndef' && block.condition === 'env-cloud') {
308
+ override.selfHostedContent = override.selfHostedContent || {}
309
+ override.selfHostedContent.after_flags = content
310
+ hasCustomContent = true
311
+ }
312
+ }
313
+ }
314
+
315
+ return hasCustomContent ? override : null
316
+ }
317
+
318
+ /**
319
+ * Scan a directory of rpk docs and extract override suggestions
320
+ * @param {string} docsDir - Path to rpk docs directory
321
+ * @param {Object} existingOverrides - Current overrides to merge with
322
+ * @returns {Object} Results with suggestions and report
323
+ */
324
+ function extractOverridesFromDocs(docsDir, existingOverrides = {}) {
325
+ const results = {
326
+ suggestions: {},
327
+ report: {
328
+ totalFiles: 0,
329
+ filesWithCustomContent: 0,
330
+ newOverrides: 0,
331
+ updatedOverrides: 0,
332
+ details: []
333
+ }
334
+ }
335
+
336
+ const existingCommands = existingOverrides.commands || {}
337
+
338
+ function processDirectory(dir) {
339
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
340
+
341
+ for (const entry of entries) {
342
+ const fullPath = path.join(dir, entry.name)
343
+
344
+ if (entry.isDirectory()) {
345
+ processDirectory(fullPath)
346
+ } else if (entry.isFile() && entry.name.endsWith('.adoc')) {
347
+ results.report.totalFiles++
348
+
349
+ try {
350
+ const content = fs.readFileSync(fullPath, 'utf8')
351
+ const parsed = parseAsciiDoc(content)
352
+ const commandPath = filePathToCommandPath(fullPath, docsDir)
353
+
354
+ const suggestion = extractOverrideSuggestion(parsed, commandPath)
355
+
356
+ if (suggestion) {
357
+ results.report.filesWithCustomContent++
358
+
359
+ const existing = existingCommands[commandPath]
360
+ if (existing) {
361
+ // Merge with existing, only add new fields
362
+ const merged = { ...existing }
363
+ let hasNewContent = false
364
+
365
+ for (const [key, value] of Object.entries(suggestion)) {
366
+ if (!existing[key]) {
367
+ merged[key] = value
368
+ hasNewContent = true
369
+ }
370
+ }
371
+
372
+ if (hasNewContent) {
373
+ results.suggestions[commandPath] = merged
374
+ results.report.updatedOverrides++
375
+ results.report.details.push({
376
+ file: fullPath,
377
+ commandPath,
378
+ action: 'update',
379
+ newFields: Object.keys(suggestion).filter(k => !existing[k])
380
+ })
381
+ }
382
+ } else {
383
+ results.suggestions[commandPath] = suggestion
384
+ results.report.newOverrides++
385
+ results.report.details.push({
386
+ file: fullPath,
387
+ commandPath,
388
+ action: 'new',
389
+ fields: Object.keys(suggestion)
390
+ })
391
+ }
392
+ }
393
+ } catch (err) {
394
+ console.error(`Error processing ${fullPath}: ${err.message}`)
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ processDirectory(docsDir)
401
+ return results
402
+ }
403
+
404
+ /**
405
+ * Generate a migration report
406
+ * @param {Object} results - Results from extractOverridesFromDocs
407
+ * @returns {string} Markdown report
408
+ */
409
+ function generateReport(results) {
410
+ const lines = [
411
+ '# RPK Docs Override Migration Report',
412
+ '',
413
+ '## Summary',
414
+ '',
415
+ `- **Total files scanned:** ${results.report.totalFiles}`,
416
+ `- **Files with custom content:** ${results.report.filesWithCustomContent}`,
417
+ `- **New overrides to add:** ${results.report.newOverrides}`,
418
+ `- **Existing overrides to update:** ${results.report.updatedOverrides}`,
419
+ ''
420
+ ]
421
+
422
+ if (results.report.details.length > 0) {
423
+ lines.push('## Details', '')
424
+
425
+ const newOverrides = results.report.details.filter(d => d.action === 'new')
426
+ const updates = results.report.details.filter(d => d.action === 'update')
427
+
428
+ if (newOverrides.length > 0) {
429
+ lines.push('### New Overrides', '')
430
+ for (const detail of newOverrides) {
431
+ lines.push(`- **${detail.commandPath}**`)
432
+ lines.push(` - File: \`${detail.file}\``)
433
+ lines.push(` - Content types: ${detail.fields.join(', ')}`)
434
+ lines.push('')
435
+ }
436
+ }
437
+
438
+ if (updates.length > 0) {
439
+ lines.push('### Updates to Existing Overrides', '')
440
+ for (const detail of updates) {
441
+ lines.push(`- **${detail.commandPath}**`)
442
+ lines.push(` - File: \`${detail.file}\``)
443
+ lines.push(` - New fields: ${detail.newFields.join(', ')}`)
444
+ lines.push('')
445
+ }
446
+ }
447
+ }
448
+
449
+ return lines.join('\n')
450
+ }
451
+
452
+ /**
453
+ * Main extraction function
454
+ * @param {string} docsDir - Path to rpk docs directory
455
+ * @param {string} overridesPath - Path to existing overrides JSON
456
+ * @param {Object} options - Options
457
+ * @returns {Object} Results
458
+ */
459
+ function extractOverrides(docsDir, overridesPath, options = {}) {
460
+ // Load existing overrides
461
+ let existingOverrides = {}
462
+ if (overridesPath && fs.existsSync(overridesPath)) {
463
+ try {
464
+ existingOverrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8'))
465
+ } catch (err) {
466
+ console.warn(`Warning: Could not load existing overrides: ${err.message}`)
467
+ }
468
+ }
469
+
470
+ // Extract suggestions
471
+ const results = extractOverridesFromDocs(docsDir, existingOverrides)
472
+
473
+ // Generate outputs
474
+ const report = generateReport(results)
475
+
476
+ // Build merged overrides
477
+ const mergedOverrides = {
478
+ ...existingOverrides,
479
+ commands: {
480
+ ...(existingOverrides.commands || {}),
481
+ ...results.suggestions
482
+ }
483
+ }
484
+
485
+ return {
486
+ suggestions: results.suggestions,
487
+ report,
488
+ mergedOverrides,
489
+ stats: results.report
490
+ }
491
+ }
492
+
493
+ // CLI interface
494
+ if (require.main === module) {
495
+ const args = process.argv.slice(2)
496
+
497
+ if (args.length < 1) {
498
+ console.log('Usage: node extract-overrides.js <docs-dir> [overrides-path] [--output-json <path>] [--output-report <path>]')
499
+ console.log('')
500
+ console.log('Arguments:')
501
+ console.log(' docs-dir Path to rpk docs directory (e.g., ../docs/modules/reference/pages/rpk)')
502
+ console.log(' overrides-path Path to existing rpk-overrides.json (optional)')
503
+ console.log('')
504
+ console.log('Options:')
505
+ console.log(' --output-json <path> Write merged overrides to file')
506
+ console.log(' --output-report <path> Write migration report to file')
507
+ console.log(' --dry-run Print report without writing files')
508
+ process.exit(1)
509
+ }
510
+
511
+ const docsDir = args[0]
512
+ let overridesPath = null
513
+ let outputJson = null
514
+ let outputReport = null
515
+ let dryRun = false
516
+
517
+ for (let i = 1; i < args.length; i++) {
518
+ if (args[i] === '--output-json' && args[i + 1]) {
519
+ outputJson = args[++i]
520
+ } else if (args[i] === '--output-report' && args[i + 1]) {
521
+ outputReport = args[++i]
522
+ } else if (args[i] === '--dry-run') {
523
+ dryRun = true
524
+ } else if (!args[i].startsWith('--')) {
525
+ overridesPath = args[i]
526
+ }
527
+ }
528
+
529
+ if (!fs.existsSync(docsDir)) {
530
+ console.error(`Error: Docs directory not found: ${docsDir}`)
531
+ process.exit(1)
532
+ }
533
+
534
+ console.log(`Extracting overrides from: ${docsDir}`)
535
+ if (overridesPath) {
536
+ console.log(`Using existing overrides: ${overridesPath}`)
537
+ }
538
+
539
+ const results = extractOverrides(docsDir, overridesPath)
540
+
541
+ console.log('')
542
+ console.log(results.report)
543
+
544
+ if (!dryRun) {
545
+ if (outputJson) {
546
+ fs.writeFileSync(outputJson, JSON.stringify(results.mergedOverrides, null, 2))
547
+ console.log(`\nWrote merged overrides to: ${outputJson}`)
548
+ }
549
+
550
+ if (outputReport) {
551
+ fs.writeFileSync(outputReport, results.report)
552
+ console.log(`Wrote report to: ${outputReport}`)
553
+ }
554
+ }
555
+
556
+ console.log(`\nFound ${Object.keys(results.suggestions).length} commands with custom content to migrate`)
557
+ }
558
+
559
+ module.exports = {
560
+ extractOverrides,
561
+ extractOverridesFromDocs,
562
+ parseAsciiDoc,
563
+ extractOverrideSuggestion,
564
+ generateReport,
565
+ filePathToCommandPath
566
+ }