@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.
@@ -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
+ }