@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.
- 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/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,414 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validation rules for generated rpk documentation
|
|
8
|
+
* Each rule returns an array of issues found
|
|
9
|
+
*/
|
|
10
|
+
const VALIDATION_RULES = [
|
|
11
|
+
{
|
|
12
|
+
name: 'raw-external-urls',
|
|
13
|
+
description: 'Raw external URLs that should be links or removed',
|
|
14
|
+
severity: 'warning',
|
|
15
|
+
// Allow certain external URLs that are intentional
|
|
16
|
+
allowlist: [
|
|
17
|
+
'https://github.com/redpanda-data/', // GitHub links are OK as links
|
|
18
|
+
],
|
|
19
|
+
check: (content, filePath) => {
|
|
20
|
+
const issues = []
|
|
21
|
+
// Match URLs not inside link:[] or xref:[]
|
|
22
|
+
const urlPattern = /(?<!link:)(?<!xref:)(https?:\/\/[^\s\[\]]+)/g
|
|
23
|
+
let match
|
|
24
|
+
while ((match = urlPattern.exec(content)) !== null) {
|
|
25
|
+
const url = match[1]
|
|
26
|
+
// Check if it's a docs.redpanda.com URL (should always be xref)
|
|
27
|
+
if (url.includes('docs.redpanda.com')) {
|
|
28
|
+
issues.push({
|
|
29
|
+
line: getLineNumber(content, match.index),
|
|
30
|
+
message: `Raw docs.redpanda.com URL should be an xref: ${url}`,
|
|
31
|
+
severity: 'error'
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return issues
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'invalid-xref-syntax',
|
|
40
|
+
description: 'xref: followed by http URL (invalid)',
|
|
41
|
+
severity: 'error',
|
|
42
|
+
check: (content, filePath) => {
|
|
43
|
+
const issues = []
|
|
44
|
+
const pattern = /xref:https?:\/\/[^\[]+/g
|
|
45
|
+
let match
|
|
46
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
47
|
+
issues.push({
|
|
48
|
+
line: getLineNumber(content, match.index),
|
|
49
|
+
message: `Invalid xref syntax (cannot use URLs): ${match[0]}`,
|
|
50
|
+
severity: 'error'
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return issues
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'markdown-emphasis',
|
|
58
|
+
description: 'Markdown-style **text** should be AsciiDoc',
|
|
59
|
+
severity: 'warning',
|
|
60
|
+
check: (content, filePath) => {
|
|
61
|
+
const issues = []
|
|
62
|
+
// Match **WORD** patterns (markdown bold)
|
|
63
|
+
const pattern = /\*\*([A-Z]+)\*\*/g
|
|
64
|
+
let match
|
|
65
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
66
|
+
issues.push({
|
|
67
|
+
line: getLineNumber(content, match.index),
|
|
68
|
+
message: `Markdown emphasis should be AsciiDoc: ${match[0]} → use admonition block instead`,
|
|
69
|
+
severity: 'warning'
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return issues
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'truncated-description',
|
|
77
|
+
description: 'Description appears truncated or garbled',
|
|
78
|
+
severity: 'error',
|
|
79
|
+
check: (content, filePath) => {
|
|
80
|
+
const issues = []
|
|
81
|
+
const descMatch = content.match(/^:description:\s*(.+)$/m)
|
|
82
|
+
if (descMatch) {
|
|
83
|
+
const desc = descMatch[1]
|
|
84
|
+
// Check for signs of truncation
|
|
85
|
+
if (desc.length < 20 && !desc.match(/^(List|Get|Set|Create|Delete|Update|Show|Print|Run|Start|Stop)\b/)) {
|
|
86
|
+
// Very short and doesn't start with a verb
|
|
87
|
+
if (desc.match(/^[a-z]/) || desc.match(/[^.!?]$/) || desc.match(/^\W/)) {
|
|
88
|
+
issues.push({
|
|
89
|
+
line: getLineNumber(content, descMatch.index),
|
|
90
|
+
message: `Description appears truncated: "${desc}"`,
|
|
91
|
+
severity: 'error'
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Check for garbled content (starts with punctuation or code, but allow backtick-wrapped words)
|
|
96
|
+
if ((desc.match(/^[)}\]'"]/) || desc.match(/^\s*[a-z]+\(\)/)) && !desc.match(/^`[a-z]+`/i)) {
|
|
97
|
+
issues.push({
|
|
98
|
+
line: getLineNumber(content, descMatch.index),
|
|
99
|
+
message: `Description appears garbled: "${desc}"`,
|
|
100
|
+
severity: 'error'
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return issues
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'inline-allcaps-warning',
|
|
109
|
+
description: 'ALLCAPS warnings in middle of paragraph (not standalone admonitions)',
|
|
110
|
+
severity: 'warning',
|
|
111
|
+
check: (content, filePath) => {
|
|
112
|
+
const issues = []
|
|
113
|
+
const lines = content.split('\n')
|
|
114
|
+
let inCodeBlock = false
|
|
115
|
+
|
|
116
|
+
for (let i = 0; i < lines.length; i++) {
|
|
117
|
+
const line = lines[i]
|
|
118
|
+
|
|
119
|
+
// Track code blocks
|
|
120
|
+
if (line.startsWith('----') || line.startsWith('....')) {
|
|
121
|
+
inCodeBlock = !inCodeBlock
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (inCodeBlock) continue
|
|
125
|
+
|
|
126
|
+
// Skip proper AsciiDoc admonitions:
|
|
127
|
+
// - [WARNING] style block markers
|
|
128
|
+
// - Standalone KEYWORD: lines (valid single-line admonitions)
|
|
129
|
+
if (line.match(/^\[(WARNING|IMPORTANT|NOTE|CAUTION|TIP)\]$/)) continue
|
|
130
|
+
|
|
131
|
+
// Check for ALLCAPS warnings that are NOT at start of line (inline in text)
|
|
132
|
+
// e.g., "This is text WARNING: more text" - this is wrong
|
|
133
|
+
const inlineMatch = line.match(/\S\s+(WARNING|IMPORTANT|CAUTION|DEPRECATED):\s/)
|
|
134
|
+
if (inlineMatch) {
|
|
135
|
+
issues.push({
|
|
136
|
+
line: i + 1,
|
|
137
|
+
message: `Inline ${inlineMatch[1]}: found mid-sentence - should be standalone admonition`,
|
|
138
|
+
severity: 'warning'
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return issues
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'coming-soon-placeholder',
|
|
147
|
+
description: 'Coming soon placeholder content',
|
|
148
|
+
severity: 'info',
|
|
149
|
+
check: (content, filePath) => {
|
|
150
|
+
const issues = []
|
|
151
|
+
if (content.toLowerCase().includes('coming soon')) {
|
|
152
|
+
issues.push({
|
|
153
|
+
line: 1,
|
|
154
|
+
message: 'Contains "coming soon" placeholder - may need documentation when feature ships',
|
|
155
|
+
severity: 'info'
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
return issues
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'bang-bang-callout',
|
|
163
|
+
description: '!!TEXT!! style callouts are not allowed',
|
|
164
|
+
severity: 'error',
|
|
165
|
+
check: (content, filePath) => {
|
|
166
|
+
const issues = []
|
|
167
|
+
const pattern = /!![A-Z]+!!/g
|
|
168
|
+
let match
|
|
169
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
170
|
+
issues.push({
|
|
171
|
+
line: getLineNumber(content, match.index),
|
|
172
|
+
message: `!!CALLOUT!! style not allowed: ${match[0]}`,
|
|
173
|
+
severity: 'error'
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
return issues
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'duplicate-content',
|
|
181
|
+
description: 'Duplicate paragraphs or sections',
|
|
182
|
+
severity: 'warning',
|
|
183
|
+
check: (content, filePath) => {
|
|
184
|
+
const issues = []
|
|
185
|
+
// Split into paragraphs (double newline separated)
|
|
186
|
+
const paragraphs = content.split(/\n\n+/).filter(p => p.trim().length > 100)
|
|
187
|
+
const seen = new Map()
|
|
188
|
+
|
|
189
|
+
for (const para of paragraphs) {
|
|
190
|
+
// Skip table rows, code blocks, and list items
|
|
191
|
+
if (para.startsWith('|') || para.startsWith('----') ||
|
|
192
|
+
para.startsWith('* ') || para.startsWith('[')) continue
|
|
193
|
+
|
|
194
|
+
const normalized = para.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
195
|
+
// Only check substantial prose paragraphs (longer threshold)
|
|
196
|
+
if (normalized.length > 100) {
|
|
197
|
+
if (seen.has(normalized)) {
|
|
198
|
+
issues.push({
|
|
199
|
+
line: getLineNumber(content, content.indexOf(para)),
|
|
200
|
+
message: 'Duplicate paragraph detected',
|
|
201
|
+
severity: 'warning'
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
seen.set(normalized, true)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return issues
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get line number for a character index
|
|
214
|
+
*/
|
|
215
|
+
function getLineNumber(content, index) {
|
|
216
|
+
return content.substring(0, index).split('\n').length
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validate a single file
|
|
221
|
+
* @param {string} filePath - Path to the file
|
|
222
|
+
* @param {Object} options - Validation options
|
|
223
|
+
* @returns {Object} Validation result
|
|
224
|
+
*/
|
|
225
|
+
function validateFile(filePath, options = {}) {
|
|
226
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
227
|
+
const relativePath = options.basePath ? path.relative(options.basePath, filePath) : filePath
|
|
228
|
+
|
|
229
|
+
const result = {
|
|
230
|
+
file: relativePath,
|
|
231
|
+
errors: [],
|
|
232
|
+
warnings: [],
|
|
233
|
+
info: []
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const rule of VALIDATION_RULES) {
|
|
237
|
+
// Skip rules if filtered
|
|
238
|
+
if (options.skipRules && options.skipRules.includes(rule.name)) continue
|
|
239
|
+
if (options.onlyRules && !options.onlyRules.includes(rule.name)) continue
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const issues = rule.check(content, filePath)
|
|
243
|
+
for (const issue of issues) {
|
|
244
|
+
const entry = {
|
|
245
|
+
rule: rule.name,
|
|
246
|
+
line: issue.line,
|
|
247
|
+
message: issue.message
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const severity = issue.severity || rule.severity
|
|
251
|
+
if (severity === 'error') {
|
|
252
|
+
result.errors.push(entry)
|
|
253
|
+
} else if (severity === 'warning') {
|
|
254
|
+
result.warnings.push(entry)
|
|
255
|
+
} else {
|
|
256
|
+
result.info.push(entry)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
result.errors.push({
|
|
261
|
+
rule: rule.name,
|
|
262
|
+
line: 0,
|
|
263
|
+
message: `Rule execution failed: ${err.message}`
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Validate all files in a directory
|
|
273
|
+
* @param {string} dirPath - Directory to validate
|
|
274
|
+
* @param {Object} options - Validation options
|
|
275
|
+
* @returns {Object} Validation summary
|
|
276
|
+
*/
|
|
277
|
+
function validateDirectory(dirPath, options = {}) {
|
|
278
|
+
const results = []
|
|
279
|
+
const summary = {
|
|
280
|
+
totalFiles: 0,
|
|
281
|
+
filesWithErrors: 0,
|
|
282
|
+
filesWithWarnings: 0,
|
|
283
|
+
totalErrors: 0,
|
|
284
|
+
totalWarnings: 0,
|
|
285
|
+
totalInfo: 0,
|
|
286
|
+
byRule: {}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function walkDir(dir) {
|
|
290
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
const fullPath = path.join(dir, entry.name)
|
|
293
|
+
if (entry.isDirectory()) {
|
|
294
|
+
walkDir(fullPath)
|
|
295
|
+
} else if (entry.name.endsWith('.adoc')) {
|
|
296
|
+
summary.totalFiles++
|
|
297
|
+
const result = validateFile(fullPath, { ...options, basePath: dirPath })
|
|
298
|
+
|
|
299
|
+
if (result.errors.length > 0) summary.filesWithErrors++
|
|
300
|
+
if (result.warnings.length > 0) summary.filesWithWarnings++
|
|
301
|
+
summary.totalErrors += result.errors.length
|
|
302
|
+
summary.totalWarnings += result.warnings.length
|
|
303
|
+
summary.totalInfo += result.info.length
|
|
304
|
+
|
|
305
|
+
// Track by rule
|
|
306
|
+
for (const err of [...result.errors, ...result.warnings, ...result.info]) {
|
|
307
|
+
if (!summary.byRule[err.rule]) {
|
|
308
|
+
summary.byRule[err.rule] = { errors: 0, warnings: 0, info: 0 }
|
|
309
|
+
}
|
|
310
|
+
if (result.errors.includes(err)) summary.byRule[err.rule].errors++
|
|
311
|
+
else if (result.warnings.includes(err)) summary.byRule[err.rule].warnings++
|
|
312
|
+
else summary.byRule[err.rule].info++
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (result.errors.length > 0 || result.warnings.length > 0 ||
|
|
316
|
+
(options.showInfo && result.info.length > 0)) {
|
|
317
|
+
results.push(result)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
walkDir(dirPath)
|
|
324
|
+
|
|
325
|
+
return { results, summary }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Format validation results for console output
|
|
330
|
+
*/
|
|
331
|
+
function formatResults(validationOutput, options = {}) {
|
|
332
|
+
const { results, summary } = validationOutput
|
|
333
|
+
const lines = []
|
|
334
|
+
|
|
335
|
+
lines.push('\n' + '='.repeat(60))
|
|
336
|
+
lines.push('RPK DOCUMENTATION VALIDATION REPORT')
|
|
337
|
+
lines.push('='.repeat(60))
|
|
338
|
+
|
|
339
|
+
// Summary
|
|
340
|
+
lines.push(`\nFiles scanned: ${summary.totalFiles}`)
|
|
341
|
+
lines.push(`Files with errors: ${summary.filesWithErrors}`)
|
|
342
|
+
lines.push(`Files with warnings: ${summary.filesWithWarnings}`)
|
|
343
|
+
lines.push(`Total errors: ${summary.totalErrors}`)
|
|
344
|
+
lines.push(`Total warnings: ${summary.totalWarnings}`)
|
|
345
|
+
|
|
346
|
+
// By rule
|
|
347
|
+
if (Object.keys(summary.byRule).length > 0) {
|
|
348
|
+
lines.push('\nIssues by rule:')
|
|
349
|
+
for (const [rule, counts] of Object.entries(summary.byRule)) {
|
|
350
|
+
const parts = []
|
|
351
|
+
if (counts.errors > 0) parts.push(`${counts.errors} errors`)
|
|
352
|
+
if (counts.warnings > 0) parts.push(`${counts.warnings} warnings`)
|
|
353
|
+
if (counts.info > 0) parts.push(`${counts.info} info`)
|
|
354
|
+
lines.push(` ${rule}: ${parts.join(', ')}`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Details
|
|
359
|
+
if (results.length > 0 && !options.summaryOnly) {
|
|
360
|
+
lines.push('\n' + '-'.repeat(60))
|
|
361
|
+
lines.push('DETAILS')
|
|
362
|
+
lines.push('-'.repeat(60))
|
|
363
|
+
|
|
364
|
+
for (const result of results) {
|
|
365
|
+
lines.push(`\n${result.file}:`)
|
|
366
|
+
for (const err of result.errors) {
|
|
367
|
+
lines.push(` ❌ [${err.rule}] Line ${err.line}: ${err.message}`)
|
|
368
|
+
}
|
|
369
|
+
for (const warn of result.warnings) {
|
|
370
|
+
lines.push(` ⚠️ [${warn.rule}] Line ${warn.line}: ${warn.message}`)
|
|
371
|
+
}
|
|
372
|
+
if (options.showInfo) {
|
|
373
|
+
for (const info of result.info) {
|
|
374
|
+
lines.push(` ℹ️ [${info.rule}] Line ${info.line}: ${info.message}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
lines.push('\n' + '='.repeat(60))
|
|
381
|
+
|
|
382
|
+
return lines.join('\n')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = {
|
|
386
|
+
validateFile,
|
|
387
|
+
validateDirectory,
|
|
388
|
+
formatResults,
|
|
389
|
+
VALIDATION_RULES
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// CLI usage
|
|
393
|
+
if (require.main === module) {
|
|
394
|
+
const args = process.argv.slice(2)
|
|
395
|
+
const dirPath = args[0] || '.'
|
|
396
|
+
|
|
397
|
+
if (!fs.existsSync(dirPath)) {
|
|
398
|
+
console.error(`Directory not found: ${dirPath}`)
|
|
399
|
+
process.exit(1)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const options = {
|
|
403
|
+
showInfo: args.includes('--info'),
|
|
404
|
+
summaryOnly: args.includes('--summary')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const output = validateDirectory(dirPath, options)
|
|
408
|
+
console.log(formatResults(output, options))
|
|
409
|
+
|
|
410
|
+
// Exit with error if any errors found
|
|
411
|
+
if (output.summary.totalErrors > 0) {
|
|
412
|
+
process.exit(1)
|
|
413
|
+
}
|
|
414
|
+
}
|