@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,624 @@
1
+ 'use strict'
2
+
3
+ const Ajv2020 = require('ajv/dist/2020')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+
7
+ /**
8
+ * Valid admonition locations for notes, warnings, tips, cautions, importants
9
+ */
10
+ const VALID_ADMONITION_LOCATIONS = [
11
+ 'after_header',
12
+ 'after_description',
13
+ 'after_usage',
14
+ 'after_aliases',
15
+ 'after_flags',
16
+ 'after_examples',
17
+ 'before_see_also',
18
+ 'end'
19
+ ]
20
+
21
+ /**
22
+ * Valid platforms
23
+ */
24
+ const VALID_PLATFORMS = ['linux', 'darwin', 'windows']
25
+
26
+ /**
27
+ * Valid include/preservation locations
28
+ */
29
+ const VALID_INCLUDE_LOCATIONS = [
30
+ 'after_header',
31
+ 'after_description',
32
+ 'after_usage',
33
+ 'after_aliases',
34
+ 'after_flags',
35
+ 'after_modifiers',
36
+ 'after_examples',
37
+ 'before_see_also',
38
+ 'end'
39
+ ]
40
+
41
+ /**
42
+ * Valid custom section positions
43
+ */
44
+ const VALID_SECTION_POSITIONS = [
45
+ 'after_description',
46
+ 'after_usage',
47
+ 'after_flags',
48
+ 'after_examples',
49
+ 'before_see_also',
50
+ 'end'
51
+ ]
52
+
53
+ /**
54
+ * Validation result object
55
+ */
56
+ class ValidationResult {
57
+ constructor() {
58
+ this.valid = true
59
+ this.errors = []
60
+ this.warnings = []
61
+ }
62
+
63
+ addError(message, context = null) {
64
+ this.valid = false
65
+ this.errors.push({ message, context })
66
+ }
67
+
68
+ addWarning(message, context = null) {
69
+ this.warnings.push({ message, context })
70
+ }
71
+
72
+ merge(other) {
73
+ if (!other.valid) this.valid = false
74
+ this.errors.push(...other.errors)
75
+ this.warnings.push(...other.warnings)
76
+ }
77
+
78
+ format() {
79
+ const lines = []
80
+ if (this.errors.length > 0) {
81
+ lines.push('ERRORS:')
82
+ for (const err of this.errors) {
83
+ lines.push(` ✗ ${err.message}`)
84
+ if (err.context) lines.push(` at: ${err.context}`)
85
+ }
86
+ }
87
+ if (this.warnings.length > 0) {
88
+ lines.push('WARNINGS:')
89
+ for (const warn of this.warnings) {
90
+ lines.push(` ⚠ ${warn.message}`)
91
+ if (warn.context) lines.push(` at: ${warn.context}`)
92
+ }
93
+ }
94
+ return lines.join('\n')
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Normalize a command path (trim whitespace, collapse multiple spaces)
100
+ * @param {string} cmdPath - Command path to normalize
101
+ * @returns {string} Normalized command path
102
+ */
103
+ function normalizeCommandPath(cmdPath) {
104
+ if (!cmdPath || typeof cmdPath !== 'string') return ''
105
+ return cmdPath.trim().replace(/\s+/g, ' ')
106
+ }
107
+
108
+ /**
109
+ * Load and compile the JSON Schema for validation
110
+ * @returns {Function} Compiled schema validator
111
+ */
112
+ function loadSchemaValidator() {
113
+ const schemaPath = path.resolve(__dirname, '../../docs-data/rpk-overrides.schema.json')
114
+
115
+ if (!fs.existsSync(schemaPath)) {
116
+ throw new Error(`Schema file not found: ${schemaPath}`)
117
+ }
118
+
119
+ const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
120
+ const ajv = new Ajv2020({ allErrors: true, verbose: true })
121
+ return ajv.compile(schema)
122
+ }
123
+
124
+ /**
125
+ * Validate overrides against JSON Schema
126
+ * @param {Object} overrides - Overrides object to validate
127
+ * @returns {ValidationResult}
128
+ */
129
+ function validateSchema(overrides) {
130
+ const result = new ValidationResult()
131
+
132
+ try {
133
+ const validate = loadSchemaValidator()
134
+ const valid = validate(overrides)
135
+
136
+ if (!valid) {
137
+ for (const error of validate.errors || []) {
138
+ const path = error.instancePath || '/'
139
+ const message = `${error.message}`
140
+ result.addError(message, path)
141
+ }
142
+ }
143
+ } catch (err) {
144
+ result.addError(`Schema validation failed: ${err.message}`)
145
+ }
146
+
147
+ return result
148
+ }
149
+
150
+ /**
151
+ * Validate command paths in overrides against actual command tree
152
+ * @param {Object} overrides - Overrides object (resolved)
153
+ * @param {Object} commandTree - rpk command tree
154
+ * @returns {ValidationResult}
155
+ */
156
+ function validateCommandPaths(overrides, commandTree) {
157
+ const result = new ValidationResult()
158
+
159
+ if (!overrides || !overrides.commands) return result
160
+ if (!commandTree) {
161
+ result.addWarning('No command tree provided, skipping command path validation')
162
+ return result
163
+ }
164
+
165
+ // Flatten command tree to get all valid paths
166
+ const validPaths = new Set()
167
+ const flattenTree = (node, parentPath = '') => {
168
+ const currentPath = parentPath ? `${parentPath} ${node.name}` : node.name
169
+ validPaths.add(currentPath)
170
+ if (node.commands && Array.isArray(node.commands)) {
171
+ for (const child of node.commands) {
172
+ flattenTree(child, currentPath)
173
+ }
174
+ }
175
+ }
176
+ flattenTree(commandTree)
177
+
178
+ // Check each override command path
179
+ for (const cmdPath of Object.keys(overrides.commands)) {
180
+ const normalized = normalizeCommandPath(cmdPath)
181
+
182
+ // Check for normalization issues
183
+ if (normalized !== cmdPath) {
184
+ result.addWarning(
185
+ `Command path has irregular whitespace: "${cmdPath}"`,
186
+ `commands["${cmdPath}"]`
187
+ )
188
+ }
189
+
190
+ // Check if path exists in tree
191
+ if (!validPaths.has(normalized)) {
192
+ // Find similar paths for suggestion
193
+ const similar = [...validPaths]
194
+ .filter(p => {
195
+ const parts = normalized.split(' ')
196
+ const pParts = p.split(' ')
197
+ return parts.some(part => pParts.includes(part))
198
+ })
199
+ .slice(0, 3)
200
+
201
+ let message = `Unknown command path: "${cmdPath}"`
202
+ if (similar.length > 0) {
203
+ message += `\n Did you mean one of: ${similar.join(', ')}?`
204
+ }
205
+ result.addError(message, `commands["${cmdPath}"]`)
206
+ }
207
+ }
208
+
209
+ return result
210
+ }
211
+
212
+ /**
213
+ * Unescape RFC 6901 JSON Pointer encoding
214
+ * @param {string} segment - Path segment to unescape
215
+ * @returns {string} Unescaped segment
216
+ */
217
+ function unescapeRfc6901(segment) {
218
+ // RFC 6901: ~1 → /, ~0 → ~ (order matters: ~1 first)
219
+ return segment.replace(/~1/g, '/').replace(/~0/g, '~')
220
+ }
221
+
222
+ /**
223
+ * Validate $ref and $refs references in overrides
224
+ * @param {Object} overrides - Raw overrides object (before resolution)
225
+ * @returns {ValidationResult}
226
+ */
227
+ function validateReferences(overrides) {
228
+ const result = new ValidationResult()
229
+
230
+ if (!overrides) return result
231
+
232
+ // Track visited refs for cycle detection
233
+ const checkRef = (ref, context, visited = new Set()) => {
234
+ if (!ref || typeof ref !== 'string') {
235
+ result.addError(`Invalid $ref value: ${ref}`, context)
236
+ return
237
+ }
238
+
239
+ // Validate ref format
240
+ if (!ref.startsWith('#/')) {
241
+ result.addError(
242
+ `Invalid $ref format: "${ref}" (must start with #/)`,
243
+ context
244
+ )
245
+ return
246
+ }
247
+
248
+ // Check for cycles
249
+ if (visited.has(ref)) {
250
+ result.addError(
251
+ `Circular reference detected: "${ref}"`,
252
+ context
253
+ )
254
+ return
255
+ }
256
+
257
+ // Resolve the reference with RFC 6901 unescaping
258
+ const refPath = ref.replace(/^#\//, '').split('/').map(unescapeRfc6901)
259
+ let resolved = overrides
260
+ for (const part of refPath) {
261
+ if (resolved && typeof resolved === 'object') {
262
+ resolved = resolved[part]
263
+ } else {
264
+ resolved = undefined
265
+ break
266
+ }
267
+ }
268
+
269
+ if (resolved === undefined) {
270
+ result.addError(
271
+ `Cannot resolve $ref: "${ref}" - path does not exist`,
272
+ context
273
+ )
274
+ return
275
+ }
276
+
277
+ // Check for nested references (cycle detection)
278
+ const newVisited = new Set(visited)
279
+ newVisited.add(ref)
280
+ checkObjectForRefs(resolved, `${context} -> ${ref}`, newVisited)
281
+ }
282
+
283
+ const checkRefs = (refs, context, visited = new Set()) => {
284
+ if (!Array.isArray(refs)) {
285
+ result.addError(`$refs must be an array`, context)
286
+ return
287
+ }
288
+ for (let i = 0; i < refs.length; i++) {
289
+ // Each array item gets a fresh copy of the visited set
290
+ // This allows the same definition to be referenced multiple times
291
+ // without being incorrectly flagged as a cycle
292
+ checkRef(refs[i], `${context}.$refs[${i}]`, new Set(visited))
293
+ }
294
+ }
295
+
296
+ // Check object for $ref/$refs with cycle tracking
297
+ const checkObjectForRefs = (obj, context = '', visited = new Set()) => {
298
+ if (!obj || typeof obj !== 'object') return
299
+ if (Array.isArray(obj)) {
300
+ obj.forEach((item, i) => checkObjectForRefs(item, `${context}[${i}]`, visited))
301
+ return
302
+ }
303
+
304
+ if (obj.$ref) {
305
+ checkRef(obj.$ref, context, visited)
306
+ }
307
+ if (obj.$refs) {
308
+ checkRefs(obj.$refs, context, visited)
309
+ }
310
+
311
+ for (const [key, value] of Object.entries(obj)) {
312
+ if (key !== '$ref' && key !== '$refs') {
313
+ checkObjectForRefs(value, context ? `${context}.${key}` : key, visited)
314
+ }
315
+ }
316
+ }
317
+
318
+ checkObjectForRefs(overrides)
319
+ return result
320
+ }
321
+
322
+ /**
323
+ * Validate admonition locations
324
+ * @param {Object} commandOverride - Single command override object
325
+ * @param {string} context - Context for error messages
326
+ * @returns {ValidationResult}
327
+ */
328
+ function validateAdmonitionLocations(commandOverride, context) {
329
+ const result = new ValidationResult()
330
+
331
+ const admonitionTypes = ['notes', 'warnings', 'tips', 'cautions', 'importants']
332
+
333
+ for (const type of admonitionTypes) {
334
+ if (commandOverride[type]) {
335
+ for (const location of Object.keys(commandOverride[type])) {
336
+ if (!VALID_ADMONITION_LOCATIONS.includes(location)) {
337
+ result.addError(
338
+ `Invalid ${type} location: "${location}"`,
339
+ `${context}.${type}.${location}`
340
+ )
341
+ result.addWarning(
342
+ `Valid locations are: ${VALID_ADMONITION_LOCATIONS.join(', ')}`
343
+ )
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ return result
350
+ }
351
+
352
+ /**
353
+ * Validate include locations
354
+ * @param {Object} commandOverride - Single command override object
355
+ * @param {string} context - Context for error messages
356
+ * @returns {ValidationResult}
357
+ */
358
+ function validateIncludeLocations(commandOverride, context) {
359
+ const result = new ValidationResult()
360
+
361
+ if (commandOverride.includes) {
362
+ for (const location of Object.keys(commandOverride.includes)) {
363
+ if (!VALID_INCLUDE_LOCATIONS.includes(location)) {
364
+ result.addError(
365
+ `Invalid include location: "${location}"`,
366
+ `${context}.includes.${location}`
367
+ )
368
+ result.addWarning(
369
+ `Valid locations are: ${VALID_INCLUDE_LOCATIONS.join(', ')}`
370
+ )
371
+ }
372
+ }
373
+ }
374
+
375
+ // Check cloudContent and selfHostedContent
376
+ for (const contentType of ['cloudContent', 'selfHostedContent']) {
377
+ if (commandOverride[contentType]) {
378
+ for (const location of Object.keys(commandOverride[contentType])) {
379
+ if (!VALID_INCLUDE_LOCATIONS.includes(location)) {
380
+ result.addError(
381
+ `Invalid ${contentType} location: "${location}"`,
382
+ `${context}.${contentType}.${location}`
383
+ )
384
+ }
385
+ }
386
+ }
387
+ }
388
+
389
+ return result
390
+ }
391
+
392
+ /**
393
+ * Validate custom section positions
394
+ * @param {Object} commandOverride - Single command override object
395
+ * @param {string} context - Context for error messages
396
+ * @returns {ValidationResult}
397
+ */
398
+ function validateCustomSectionPositions(commandOverride, context) {
399
+ const result = new ValidationResult()
400
+
401
+ if (commandOverride.customSections) {
402
+ for (const [name, section] of Object.entries(commandOverride.customSections)) {
403
+ if (section.position && !VALID_SECTION_POSITIONS.includes(section.position)) {
404
+ result.addError(
405
+ `Invalid custom section position: "${section.position}"`,
406
+ `${context}.customSections.${name}.position`
407
+ )
408
+ result.addWarning(
409
+ `Valid positions are: ${VALID_SECTION_POSITIONS.join(', ')}`
410
+ )
411
+ }
412
+ }
413
+ }
414
+
415
+ return result
416
+ }
417
+
418
+ /**
419
+ * Validate platform values
420
+ * @param {Object} commandOverride - Single command override object
421
+ * @param {string} context - Context for error messages
422
+ * @returns {ValidationResult}
423
+ */
424
+ function validatePlatforms(commandOverride, context) {
425
+ const result = new ValidationResult()
426
+
427
+ if (commandOverride.platforms) {
428
+ for (const platform of commandOverride.platforms) {
429
+ if (!VALID_PLATFORMS.includes(platform)) {
430
+ result.addError(
431
+ `Invalid platform: "${platform}"`,
432
+ `${context}.platforms`
433
+ )
434
+ result.addWarning(
435
+ `Valid platforms are: ${VALID_PLATFORMS.join(', ')}`
436
+ )
437
+ }
438
+ }
439
+ }
440
+
441
+ return result
442
+ }
443
+
444
+ /**
445
+ * Validate flag overrides
446
+ * @param {Object} commandOverride - Single command override object
447
+ * @param {string} context - Context for error messages
448
+ * @returns {ValidationResult}
449
+ */
450
+ function validateFlags(commandOverride, context) {
451
+ const result = new ValidationResult()
452
+
453
+ if (commandOverride.flags) {
454
+ for (const [flagName, flagOverride] of Object.entries(commandOverride.flags)) {
455
+ // Validate flag name format
456
+ if (!flagName.match(/^[a-zA-Z][-a-zA-Z0-9]*$/)) {
457
+ result.addWarning(
458
+ `Unusual flag name format: "${flagName}"`,
459
+ `${context}.flags.${flagName}`
460
+ )
461
+ }
462
+
463
+ // Validate type if specified
464
+ if (flagOverride.type) {
465
+ const validTypes = ['bool', 'string', 'int', 'int32', 'int64', 'uint', 'uint32',
466
+ 'uint64', 'float', 'float32', 'float64', 'duration',
467
+ 'stringSlice', 'stringArray', 'intSlice', '-']
468
+ if (!validTypes.includes(flagOverride.type)) {
469
+ result.addWarning(
470
+ `Unusual flag type: "${flagOverride.type}" for flag "${flagName}"`,
471
+ `${context}.flags.${flagName}.type`
472
+ )
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ return result
479
+ }
480
+
481
+ /**
482
+ * Validate description content (check for common issues)
483
+ * @param {Object} commandOverride - Single command override object
484
+ * @param {string} context - Context for error messages
485
+ * @returns {ValidationResult}
486
+ */
487
+ function validateDescriptions(commandOverride, context) {
488
+ const result = new ValidationResult()
489
+
490
+ const checkDescription = (desc, descContext) => {
491
+ if (!desc || typeof desc !== 'string') return
492
+
493
+ // Check for HTML entities that should have been decoded
494
+ if (desc.match(/&#x[0-9a-fA-F]+;|&#\d+;|&[a-z]+;/)) {
495
+ result.addWarning(
496
+ `Description may contain unescaped HTML entities`,
497
+ descContext
498
+ )
499
+ }
500
+
501
+ // Check for common AsciiDoc syntax issues
502
+ if (desc.includes('[') && !desc.includes(']')) {
503
+ result.addWarning(
504
+ `Possible unclosed bracket in description`,
505
+ descContext
506
+ )
507
+ }
508
+
509
+ // Check for very long single-line descriptions (might need wrapping)
510
+ if (desc.length > 500 && !desc.includes('\n')) {
511
+ result.addWarning(
512
+ `Very long description (${desc.length} chars) without line breaks`,
513
+ descContext
514
+ )
515
+ }
516
+ }
517
+
518
+ if (commandOverride.description) {
519
+ checkDescription(commandOverride.description, `${context}.description`)
520
+ }
521
+ if (commandOverride.appendToDescription) {
522
+ checkDescription(commandOverride.appendToDescription, `${context}.appendToDescription`)
523
+ }
524
+
525
+ return result
526
+ }
527
+
528
+ /**
529
+ * Run all validations on an overrides object
530
+ * @param {Object} overrides - Overrides object to validate
531
+ * @param {Object} [commandTree] - Optional command tree for path validation
532
+ * @returns {ValidationResult}
533
+ */
534
+ function validateOverrides(overrides, commandTree = null) {
535
+ const result = new ValidationResult()
536
+
537
+ if (!overrides || typeof overrides !== 'object') {
538
+ result.addError('Overrides must be a non-null object')
539
+ return result
540
+ }
541
+
542
+ // 1. Schema validation
543
+ result.merge(validateSchema(overrides))
544
+
545
+ // 2. Reference validation
546
+ result.merge(validateReferences(overrides))
547
+
548
+ // 3. Command path validation (if tree provided)
549
+ if (commandTree) {
550
+ result.merge(validateCommandPaths(overrides, commandTree))
551
+ }
552
+
553
+ // 4. Per-command validations
554
+ if (overrides.commands) {
555
+ for (const [cmdPath, cmdOverride] of Object.entries(overrides.commands)) {
556
+ const context = `commands["${cmdPath}"]`
557
+
558
+ result.merge(validateAdmonitionLocations(cmdOverride, context))
559
+ result.merge(validateIncludeLocations(cmdOverride, context))
560
+ result.merge(validateCustomSectionPositions(cmdOverride, context))
561
+ result.merge(validatePlatforms(cmdOverride, context))
562
+ result.merge(validateFlags(cmdOverride, context))
563
+ result.merge(validateDescriptions(cmdOverride, context))
564
+ }
565
+ }
566
+
567
+ return result
568
+ }
569
+
570
+ /**
571
+ * Load and validate overrides file
572
+ * @param {string} overridesPath - Path to overrides JSON file
573
+ * @param {Object} [commandTree] - Optional command tree for path validation
574
+ * @returns {{ overrides: Object|null, validation: ValidationResult }}
575
+ */
576
+ function loadAndValidateOverrides(overridesPath, commandTree = null) {
577
+ const validation = new ValidationResult()
578
+
579
+ if (!overridesPath) {
580
+ return { overrides: null, validation }
581
+ }
582
+
583
+ if (!fs.existsSync(overridesPath)) {
584
+ validation.addWarning(`Overrides file not found: ${overridesPath}`)
585
+ return { overrides: null, validation }
586
+ }
587
+
588
+ // Parse JSON
589
+ let overrides
590
+ try {
591
+ const content = fs.readFileSync(overridesPath, 'utf8')
592
+ overrides = JSON.parse(content)
593
+ } catch (err) {
594
+ validation.addError(`Failed to parse overrides JSON: ${err.message}`, overridesPath)
595
+ return { overrides: null, validation }
596
+ }
597
+
598
+ // Run validations
599
+ validation.merge(validateOverrides(overrides, commandTree))
600
+
601
+ return { overrides, validation }
602
+ }
603
+
604
+ module.exports = {
605
+ ValidationResult,
606
+ normalizeCommandPath,
607
+ loadSchemaValidator,
608
+ validateSchema,
609
+ validateCommandPaths,
610
+ validateReferences,
611
+ validateAdmonitionLocations,
612
+ validateIncludeLocations,
613
+ validateCustomSectionPositions,
614
+ validatePlatforms,
615
+ validateFlags,
616
+ validateDescriptions,
617
+ validateOverrides,
618
+ loadAndValidateOverrides,
619
+ unescapeRfc6901,
620
+ VALID_ADMONITION_LOCATIONS,
621
+ VALID_PLATFORMS,
622
+ VALID_INCLUDE_LOCATIONS,
623
+ VALID_SECTION_POSITIONS
624
+ }