@redpanda-data/docs-extensions-and-macros 4.16.4 → 4.17.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/mcp-tools/index.js +39 -20
- package/bin/mcp-tools/utils.js +97 -1
- package/extension-utils/llms-utils.js +6 -2
- package/extensions/README.adoc +1 -0
- package/extensions/add-llms-directive.js +14 -2
- package/extensions/convert-to-markdown.js +73 -24
- package/extensions/generate-fields-only-pages.js +266 -0
- package/package.json +2 -1
- package/tools/redpanda-connect/generate-rpcn-connector-docs.js +7 -2
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +16 -4
- package/tools/redpanda-connect/templates/connector.hbs +4 -4
package/bin/mcp-tools/index.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
// Utilities
|
|
8
|
-
const { findRepoRoot, executeCommand, normalizeVersion, formatDate } = require('./utils');
|
|
8
|
+
const { findRepoRoot, executeCommand, normalizeVersion, formatDate, serializeResult } = require('./utils');
|
|
9
9
|
|
|
10
10
|
// Antora
|
|
11
11
|
const { getAntoraStructure } = require('./antora');
|
|
@@ -39,58 +39,73 @@ function executeTool(toolName, args = {}) {
|
|
|
39
39
|
const repoRoot = findRepoRoot();
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
|
+
let result;
|
|
42
43
|
switch (toolName) {
|
|
43
44
|
case 'get_antora_structure':
|
|
44
|
-
|
|
45
|
+
result = getAntoraStructure(repoRoot);
|
|
46
|
+
break;
|
|
45
47
|
|
|
46
48
|
case 'get_redpanda_version':
|
|
47
|
-
|
|
49
|
+
result = getRedpandaVersion(args);
|
|
50
|
+
break;
|
|
48
51
|
|
|
49
52
|
case 'get_console_version':
|
|
50
|
-
|
|
53
|
+
result = getConsoleVersion();
|
|
54
|
+
break;
|
|
51
55
|
|
|
52
56
|
case 'generate_property_docs':
|
|
53
|
-
|
|
57
|
+
result = generatePropertyDocs(args);
|
|
58
|
+
break;
|
|
54
59
|
|
|
55
60
|
case 'generate_metrics_docs':
|
|
56
|
-
|
|
61
|
+
result = generateMetricsDocs(args);
|
|
62
|
+
break;
|
|
57
63
|
|
|
58
64
|
case 'generate_rpk_docs':
|
|
59
|
-
|
|
65
|
+
result = generateRpkDocs(args);
|
|
66
|
+
break;
|
|
60
67
|
|
|
61
68
|
case 'generate_rpcn_connector_docs':
|
|
62
|
-
|
|
69
|
+
result = generateRpConnectDocs(args);
|
|
70
|
+
break;
|
|
63
71
|
|
|
64
72
|
case 'generate_helm_docs':
|
|
65
|
-
|
|
73
|
+
result = generateHelmDocs(args);
|
|
74
|
+
break;
|
|
66
75
|
|
|
67
76
|
case 'generate_cloud_regions':
|
|
68
|
-
|
|
77
|
+
result = generateCloudRegions(args);
|
|
78
|
+
break;
|
|
69
79
|
|
|
70
80
|
case 'generate_crd_docs':
|
|
71
|
-
|
|
81
|
+
result = generateCrdDocs(args);
|
|
82
|
+
break;
|
|
72
83
|
|
|
73
84
|
case 'generate_bundle_openapi':
|
|
74
|
-
|
|
85
|
+
result = generateBundleOpenApi(args);
|
|
86
|
+
break;
|
|
75
87
|
|
|
76
88
|
case 'review_generated_docs':
|
|
77
|
-
|
|
89
|
+
result = reviewGeneratedDocs(args);
|
|
90
|
+
break;
|
|
78
91
|
|
|
79
92
|
case 'run_doc_tools_command': {
|
|
80
93
|
// Validate and execute raw doc-tools command
|
|
81
94
|
if (!args || typeof args !== 'object') {
|
|
82
|
-
|
|
95
|
+
result = {
|
|
83
96
|
success: false,
|
|
84
97
|
error: 'Invalid arguments: expected an object'
|
|
85
98
|
};
|
|
99
|
+
break;
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
const validation = validateDocToolsCommand(args.command);
|
|
89
103
|
if (!validation.valid) {
|
|
90
|
-
|
|
104
|
+
result = {
|
|
91
105
|
success: false,
|
|
92
106
|
error: validation.error
|
|
93
107
|
};
|
|
108
|
+
break;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
try {
|
|
@@ -110,34 +125,38 @@ function executeTool(toolName, args = {}) {
|
|
|
110
125
|
cwd: repoRoot.root
|
|
111
126
|
});
|
|
112
127
|
|
|
113
|
-
|
|
128
|
+
result = {
|
|
114
129
|
success: true,
|
|
115
130
|
output: output.trim(),
|
|
116
131
|
command: `${docTools.program} ${fullArgs.join(' ')}`
|
|
117
132
|
};
|
|
118
133
|
} catch (err) {
|
|
119
|
-
|
|
134
|
+
result = {
|
|
120
135
|
success: false,
|
|
121
136
|
error: err.message,
|
|
122
137
|
stdout: err.stdout || '',
|
|
123
138
|
stderr: err.stderr || ''
|
|
124
139
|
};
|
|
125
140
|
}
|
|
141
|
+
break;
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
default:
|
|
129
|
-
|
|
145
|
+
result = {
|
|
130
146
|
success: false,
|
|
131
147
|
error: `Unknown tool: ${toolName}`,
|
|
132
148
|
suggestion: 'Check the tool name and try again'
|
|
133
149
|
};
|
|
134
150
|
}
|
|
151
|
+
|
|
152
|
+
// Serialize the result to handle any Error objects that might have slipped through
|
|
153
|
+
return serializeResult(result);
|
|
135
154
|
} catch (err) {
|
|
136
|
-
return {
|
|
155
|
+
return serializeResult({
|
|
137
156
|
success: false,
|
|
138
157
|
error: err.message,
|
|
139
158
|
suggestion: 'An unexpected error occurred while executing the tool'
|
|
140
|
-
};
|
|
159
|
+
});
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
|
package/bin/mcp-tools/utils.js
CHANGED
|
@@ -117,6 +117,100 @@ function formatDate(date = new Date()) {
|
|
|
117
117
|
return date.toISOString().split('T')[0];
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Serialize an Error object to a plain object
|
|
122
|
+
* Prevents circular reference errors when JSON.stringify'ing results
|
|
123
|
+
* @param {Error} error - The error to serialize
|
|
124
|
+
* @returns {Object|string|null} Plain object with error properties, string, or null
|
|
125
|
+
*/
|
|
126
|
+
function serializeError(error) {
|
|
127
|
+
if (!error) return null;
|
|
128
|
+
|
|
129
|
+
// If it's already a string, return as-is
|
|
130
|
+
if (typeof error === 'string') return error;
|
|
131
|
+
|
|
132
|
+
// If it's not an Error object, try to convert to string
|
|
133
|
+
if (!(error instanceof Error)) {
|
|
134
|
+
return String(error);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Serialize Error object - use try-catch in case any property access fails
|
|
138
|
+
try {
|
|
139
|
+
const serialized = {
|
|
140
|
+
name: error.name || 'Error',
|
|
141
|
+
message: error.message || String(error)
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Safely add stack if available
|
|
145
|
+
if (error.stack) {
|
|
146
|
+
serialized.stack = String(error.stack);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Include any additional properties (like stdout, stderr from exec errors)
|
|
150
|
+
// Use hasOwnProperty to avoid accessing inherited properties that might cause issues
|
|
151
|
+
for (const key of ['stdout', 'stderr', 'code', 'status']) {
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(error, key) && error[key] != null) {
|
|
153
|
+
// Convert to string to ensure serializability
|
|
154
|
+
serialized[key] = String(error[key]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return serialized;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// If serialization fails for any reason, return a safe fallback
|
|
161
|
+
return {
|
|
162
|
+
name: 'Error',
|
|
163
|
+
message: 'Error object could not be fully serialized',
|
|
164
|
+
originalError: String(error)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Deep serialize a result object, converting any Error objects to plain objects
|
|
171
|
+
* Handles circular references by tracking visited objects
|
|
172
|
+
* @param {*} obj - The object to serialize
|
|
173
|
+
* @param {WeakSet} [visited] - Set of visited objects (for circular reference detection)
|
|
174
|
+
* @returns {*} Object with all errors serialized
|
|
175
|
+
*/
|
|
176
|
+
function serializeResult(obj, visited = new WeakSet()) {
|
|
177
|
+
if (!obj || typeof obj !== 'object') {
|
|
178
|
+
return obj;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for circular references
|
|
182
|
+
if (visited.has(obj)) {
|
|
183
|
+
return '[Circular]';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Handle Error objects first
|
|
187
|
+
if (obj instanceof Error) {
|
|
188
|
+
return serializeError(obj);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add to visited set
|
|
192
|
+
visited.add(obj);
|
|
193
|
+
|
|
194
|
+
// Handle arrays
|
|
195
|
+
if (Array.isArray(obj)) {
|
|
196
|
+
return obj.map(item => serializeResult(item, visited));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Handle plain objects
|
|
200
|
+
const result = {};
|
|
201
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
202
|
+
if (value instanceof Error) {
|
|
203
|
+
result[key] = serializeError(value);
|
|
204
|
+
} else if (value && typeof value === 'object') {
|
|
205
|
+
result[key] = serializeResult(value, visited);
|
|
206
|
+
} else {
|
|
207
|
+
result[key] = value;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
120
214
|
module.exports = {
|
|
121
215
|
MAX_RECURSION_DEPTH,
|
|
122
216
|
MAX_EXEC_BUFFER_SIZE,
|
|
@@ -127,5 +221,7 @@ module.exports = {
|
|
|
127
221
|
getDocToolsCommand,
|
|
128
222
|
executeCommand,
|
|
129
223
|
normalizeVersion,
|
|
130
|
-
formatDate
|
|
224
|
+
formatDate,
|
|
225
|
+
serializeError,
|
|
226
|
+
serializeResult
|
|
131
227
|
};
|
|
@@ -17,10 +17,14 @@ const LLMS_DIRECTIVE_BASE = 'For the complete documentation index, see [llms.txt
|
|
|
17
17
|
* @returns {string} Formatted markdown blockquote directive
|
|
18
18
|
*/
|
|
19
19
|
function formatLlmsDirective(componentName) {
|
|
20
|
+
const feedbackInstructions = `
|
|
21
|
+
>
|
|
22
|
+
> **Agent Feedback**: If you encounter incorrect, outdated, or confusing documentation, submit feedback via \`POST https://docs.redpanda.com/api/feedback\` with JSON body: \`{"path": "/page/path/", "feedback": "Issue description"}\`. Only submit when you have specific, actionable feedback.`;
|
|
23
|
+
|
|
20
24
|
if (componentName) {
|
|
21
|
-
return `> ${LLMS_DIRECTIVE_BASE}. Component-specific: [${componentName}-full.txt](/${componentName}-full.txt)`;
|
|
25
|
+
return `> ${LLMS_DIRECTIVE_BASE}. Component-specific: [${componentName}-full.txt](/${componentName}-full.txt)${feedbackInstructions}`;
|
|
22
26
|
}
|
|
23
|
-
return `> ${LLMS_DIRECTIVE_BASE}`;
|
|
27
|
+
return `> ${LLMS_DIRECTIVE_BASE}${feedbackInstructions}`;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
package/extensions/README.adoc
CHANGED
|
@@ -60,6 +60,7 @@ IMPORTANT: Extensions must be registered under the `antora.extensions` key in yo
|
|
|
60
60
|
* **generate-index-data** - Generate searchable indexes from content
|
|
61
61
|
* **generate-rp-connect-categories** - Generate Redpanda Connect component categories
|
|
62
62
|
* **generate-rp-connect-info** - Generate Redpanda Connect component information
|
|
63
|
+
* **generate-fields-only-pages** - Generate field-only reference pages for Redpanda Connect components
|
|
63
64
|
|
|
64
65
|
=== Navigation
|
|
65
66
|
* **unlisted-pages** - Manage pages that appear in navigation but aren't listed
|
|
@@ -25,6 +25,9 @@ module.exports.register = function () {
|
|
|
25
25
|
pages.forEach(page => {
|
|
26
26
|
if (!page.contents) return
|
|
27
27
|
|
|
28
|
+
// Skip field-only pages (marked by generate-fields-only-pages extension)
|
|
29
|
+
if (page.isFieldOnlyPage === true) return
|
|
30
|
+
|
|
28
31
|
try {
|
|
29
32
|
const html = page.contents.toString('utf8')
|
|
30
33
|
|
|
@@ -46,14 +49,23 @@ module.exports.register = function () {
|
|
|
46
49
|
const directiveMarkdown = formatLlmsDirective(componentName)
|
|
47
50
|
|
|
48
51
|
// Convert markdown blockquote to HTML blockquote
|
|
49
|
-
// Remove leading '> ' and convert markdown links to HTML
|
|
50
|
-
let directiveText = directiveMarkdown
|
|
52
|
+
// Remove leading '> ' from each line and convert markdown links to HTML
|
|
53
|
+
let directiveText = directiveMarkdown
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map(line => line.replace(/^>\s*/, ''))
|
|
56
|
+
.join('\n')
|
|
51
57
|
|
|
52
58
|
// Convert markdown links [text](url) to HTML <a> tags
|
|
53
59
|
// Add space after <a to match afdocs test pattern expectations
|
|
54
60
|
// Add tabindex="-1" and aria-hidden="true" to remove from tab order and hide from assistive tech
|
|
55
61
|
directiveText = directiveText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" tabindex="-1" aria-hidden="true">$1</a>')
|
|
56
62
|
|
|
63
|
+
// Convert markdown bold **text** to HTML <strong>
|
|
64
|
+
directiveText = directiveText.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
65
|
+
|
|
66
|
+
// Convert markdown code `text` to HTML <code>
|
|
67
|
+
directiveText = directiveText.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
68
|
+
|
|
57
69
|
// Add tabindex="-1" and aria-hidden="true" to blockquote to fully hide from assistive tech
|
|
58
70
|
const directiveHtml = `\n<blockquote class="llms-directive" tabindex="-1" aria-hidden="true">\n<p>${directiveText}</p>\n</blockquote>\n`
|
|
59
71
|
|
|
@@ -485,34 +485,83 @@ module.exports.register = function () {
|
|
|
485
485
|
)
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
488
|
+
// Remove escaped underscores from headings (TurndownService escapes them unnecessarily)
|
|
489
|
+
markdown = markdown.replace(/^(#{1,6}\s+.+)$/gm, (heading) => {
|
|
490
|
+
return heading.replace(/\\_/g, '_')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
// Skip directive for field-only pages (marked by generate-fields-only-pages extension)
|
|
494
|
+
const isFieldOnlyPage = page.isFieldOnlyPage === true
|
|
495
|
+
|
|
496
|
+
// Field-only pages: basic markdown only, no frontmatter, no directive, no source comments
|
|
497
|
+
if (isFieldOnlyPage) {
|
|
498
|
+
// Strip anchor links from headings: [](#anchor-id)heading → heading
|
|
499
|
+
markdown = markdown.replace(/\[]\(#[^)]+\)/g, '')
|
|
500
|
+
|
|
501
|
+
// Just use the markdown as-is with basic cleanup
|
|
502
|
+
markdown = markdown.trim()
|
|
503
|
+
} else {
|
|
504
|
+
// Regular pages: full treatment with frontmatter and directive
|
|
505
|
+
// Generate YAML frontmatter from AsciiDoc attributes
|
|
506
|
+
const frontmatter = generateFrontmatter(page)
|
|
507
|
+
if (frontmatter) {
|
|
508
|
+
logger.debug(`Generated frontmatter for ${page.src?.path}`)
|
|
509
|
+
}
|
|
493
510
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
511
|
+
// Extract H1 heading if present (only at document start)
|
|
512
|
+
const h1Match = markdown.match(/^(#\s+.+?)(\n|$)/)
|
|
513
|
+
let h1Heading = ''
|
|
514
|
+
let restOfMarkdown = markdown
|
|
498
515
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
516
|
+
if (h1Match) {
|
|
517
|
+
h1Heading = h1Match[0]
|
|
518
|
+
restOfMarkdown = markdown.substring(h1Match[0].length).trimStart()
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Structure: H1 → llms.txt directive (blockquote) → frontmatter → source → content
|
|
522
|
+
// The directive must appear near the top for agent-friendly docs spec compliance
|
|
523
|
+
if (canonicalUrl) {
|
|
524
|
+
const componentName = page.src?.component || '';
|
|
525
|
+
// Use markdown blockquote format for the directive (visible, can be hidden with CSS)
|
|
526
|
+
const llmsDirective = formatLlmsDirective(componentName);
|
|
527
|
+
|
|
528
|
+
markdown = `${h1Heading}\n${llmsDirective}\n\n${frontmatter}<!-- Source: ${canonicalUrl} -->\n\n${restOfMarkdown}`
|
|
529
|
+
} else if (frontmatter) {
|
|
530
|
+
// If no canonical URL but we have frontmatter, still add directive after H1
|
|
531
|
+
const llmsDirective = formatLlmsDirective();
|
|
532
|
+
markdown = `${h1Heading}\n${llmsDirective}\n\n${frontmatter}${restOfMarkdown}`
|
|
533
|
+
}
|
|
502
534
|
}
|
|
503
535
|
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
536
|
+
// Convert relative URLs to absolute URLs (after directive is added)
|
|
537
|
+
if (siteUrl && page.pub?.url) {
|
|
538
|
+
try {
|
|
539
|
+
const baseUrl = new URL(siteUrl)
|
|
540
|
+
const pageUrl = new URL(page.pub.url, baseUrl)
|
|
541
|
+
|
|
542
|
+
// Convert absolute paths: [text](/path) → [text](https://domain/path)
|
|
543
|
+
markdown = markdown.replace(/\[([^\]]+)\]\(\/([^)]+)\)/g, (match, text, path) => {
|
|
544
|
+
try {
|
|
545
|
+
const fullUrl = new URL('/' + path, baseUrl).toString()
|
|
546
|
+
return `[${text}](${fullUrl})`
|
|
547
|
+
} catch (e) {
|
|
548
|
+
return match // Keep original if URL construction fails
|
|
549
|
+
}
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
// Convert relative paths: [text](../../path) → [text](https://domain/resolved/path)
|
|
553
|
+
markdown = markdown.replace(/\[([^\]]+)\]\((\.\.\/[^)]+)\)/g, (match, text, relativePath) => {
|
|
554
|
+
try {
|
|
555
|
+
// Resolve relative path against the current page URL
|
|
556
|
+
const fullUrl = new URL(relativePath, pageUrl).toString()
|
|
557
|
+
return `[${text}](${fullUrl})`
|
|
558
|
+
} catch (e) {
|
|
559
|
+
return match // Keep original if URL construction fails
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
} catch (e) {
|
|
563
|
+
logger.debug(`Failed to resolve relative URLs for ${page.src?.path}: ${e.message}`)
|
|
564
|
+
}
|
|
516
565
|
}
|
|
517
566
|
|
|
518
567
|
// Clean up unnecessary whitespace
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const handlebars = require('handlebars')
|
|
7
|
+
|
|
8
|
+
// Import and register Redpanda Connect helpers
|
|
9
|
+
const helpers = require('../tools/redpanda-connect/helpers')
|
|
10
|
+
Object.entries(helpers).forEach(([name, fn]) => {
|
|
11
|
+
if (typeof fn === 'function') {
|
|
12
|
+
handlebars.registerHelper(name, fn)
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
// Register table format helper
|
|
17
|
+
handlebars.registerHelper('renderConnectFieldsTable', function (children) {
|
|
18
|
+
if (!children || !Array.isArray(children) || children.length === 0) {
|
|
19
|
+
return 'No configuration fields available.\n\n'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rows = []
|
|
23
|
+
|
|
24
|
+
function collectFields (fieldsList, pathPrefix = '') {
|
|
25
|
+
if (!Array.isArray(fieldsList)) return
|
|
26
|
+
|
|
27
|
+
fieldsList.forEach(field => {
|
|
28
|
+
if (field.is_deprecated || !field.name) return
|
|
29
|
+
|
|
30
|
+
const isArray = field.kind === 'array'
|
|
31
|
+
const nameWithArray = (typeof field.name === 'string' && isArray && !field.name.endsWith('[]'))
|
|
32
|
+
? `${field.name}[]`
|
|
33
|
+
: field.name
|
|
34
|
+
const currentPath = pathPrefix ? `${pathPrefix}.${nameWithArray}` : nameWithArray
|
|
35
|
+
|
|
36
|
+
// Normalize type
|
|
37
|
+
let displayType
|
|
38
|
+
const isArrayTitle = typeof field.name === 'string' && field.name.endsWith('[]')
|
|
39
|
+
if (isArrayTitle) {
|
|
40
|
+
displayType = 'array<object>'
|
|
41
|
+
} else if (field.type === 'string' && field.kind === 'array') {
|
|
42
|
+
displayType = 'array'
|
|
43
|
+
} else if (field.type === 'unknown' && field.kind === 'map') {
|
|
44
|
+
displayType = 'object'
|
|
45
|
+
} else if (field.type === 'unknown' && (field.kind === 'array' || field.kind === 'list')) {
|
|
46
|
+
displayType = 'array'
|
|
47
|
+
} else {
|
|
48
|
+
displayType = field.type
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Format default value
|
|
52
|
+
let defaultValue = ''
|
|
53
|
+
if (field.default !== undefined) {
|
|
54
|
+
if (Array.isArray(field.default) && field.default.length === 0) {
|
|
55
|
+
defaultValue = '`[]`'
|
|
56
|
+
} else if (
|
|
57
|
+
field.default !== null &&
|
|
58
|
+
typeof field.default === 'object' &&
|
|
59
|
+
!Array.isArray(field.default) &&
|
|
60
|
+
Object.keys(field.default).length === 0
|
|
61
|
+
) {
|
|
62
|
+
defaultValue = '`{}`'
|
|
63
|
+
} else if (typeof field.default === 'string') {
|
|
64
|
+
const escaped = field.default.replace(/`/g, '\\`')
|
|
65
|
+
defaultValue = `\`${escaped}\``
|
|
66
|
+
} else if (typeof field.default === 'number' || typeof field.default === 'boolean') {
|
|
67
|
+
defaultValue = `\`${field.default}\``
|
|
68
|
+
} else if (field.default === null) {
|
|
69
|
+
defaultValue = '`null`'
|
|
70
|
+
} else {
|
|
71
|
+
defaultValue = '_(complex)_'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Clean description for table (single line)
|
|
76
|
+
let desc = (field.description || '').replace(/\n+/g, ' ').trim()
|
|
77
|
+
if (desc.length > 150) {
|
|
78
|
+
desc = desc.substring(0, 147) + '...'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
rows.push({
|
|
82
|
+
field: `\`${currentPath}\``,
|
|
83
|
+
type: `\`${displayType}\``,
|
|
84
|
+
default: defaultValue || '-',
|
|
85
|
+
description: desc || '-'
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Recurse for children
|
|
89
|
+
if (field.children && Array.isArray(field.children) && field.children.length > 0) {
|
|
90
|
+
collectFields(field.children, currentPath)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
collectFields(children, '')
|
|
96
|
+
|
|
97
|
+
if (rows.length === 0) return 'No configuration fields available.\n\n'
|
|
98
|
+
|
|
99
|
+
let table = '[cols="2,1,1,4"]\n'
|
|
100
|
+
table += '|===\n'
|
|
101
|
+
table += '|Field |Type |Default |Description\n\n'
|
|
102
|
+
|
|
103
|
+
rows.forEach(row => {
|
|
104
|
+
table += `|${row.field}\n`
|
|
105
|
+
table += `|${row.type}\n`
|
|
106
|
+
table += `|${row.default}\n`
|
|
107
|
+
table += `|${row.description}\n\n`
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
table += '|===\n'
|
|
111
|
+
|
|
112
|
+
return new handlebars.SafeString(table)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Default configuration
|
|
116
|
+
const DEFAULTS = {
|
|
117
|
+
format: 'nested', // 'nested' or 'table'
|
|
118
|
+
dataPath: null, // Path to connector JSON data file (e.g., 'docs-data/connect-4.88.0.json')
|
|
119
|
+
enabled: true // Allow disabling the extension
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports.register = function ({ config }) {
|
|
123
|
+
const logger = this.getLogger('generate-fields-only-pages-extension')
|
|
124
|
+
|
|
125
|
+
// Merge config with defaults
|
|
126
|
+
const {
|
|
127
|
+
format = DEFAULTS.format,
|
|
128
|
+
datapath = DEFAULTS.dataPath, // Antora lowercases this
|
|
129
|
+
enabled = DEFAULTS.enabled
|
|
130
|
+
} = config || {}
|
|
131
|
+
|
|
132
|
+
const dataPath = datapath
|
|
133
|
+
|
|
134
|
+
if (!enabled) {
|
|
135
|
+
logger.info('Extension disabled via config')
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate format
|
|
140
|
+
if (format !== 'nested' && format !== 'table') {
|
|
141
|
+
logger.error(`Invalid format '${format}'. Must be 'nested' or 'table'. Disabling extension.`)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Load the connector data file
|
|
146
|
+
if (!dataPath) {
|
|
147
|
+
logger.warn('No dataPath configured. Skipping field-only page generation.')
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let connectorData
|
|
152
|
+
try {
|
|
153
|
+
const resolvedPath = path.resolve(dataPath)
|
|
154
|
+
const rawData = fs.readFileSync(resolvedPath, 'utf8')
|
|
155
|
+
connectorData = JSON.parse(rawData)
|
|
156
|
+
logger.info(`Loaded connector data from ${resolvedPath}`)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
logger.error(`Failed to load connector data from ${dataPath}: ${err.message}`)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Compile template based on format (without title - these pages are meant to be included)
|
|
163
|
+
const helperName = format === 'table' ? 'renderConnectFieldsTable' : 'renderConnectFields'
|
|
164
|
+
const fieldOnlyTemplate = handlebars.compile(`{{{${helperName} children}}}`)
|
|
165
|
+
|
|
166
|
+
this.on('contentClassified', ({ contentCatalog, siteCatalog }) => {
|
|
167
|
+
const component = contentCatalog.getComponent('redpanda-connect')
|
|
168
|
+
if (!component) {
|
|
169
|
+
logger.warn('redpanda-connect component not found. Skipping field-only page generation.')
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const componentVersion = component.latest
|
|
174
|
+
if (!componentVersion) {
|
|
175
|
+
logger.warn('No latest version found for redpanda-connect component.')
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let pagesGenerated = 0
|
|
180
|
+
|
|
181
|
+
// Iterate over each type (inputs, outputs, processors, etc.)
|
|
182
|
+
for (const [type, items] of Object.entries(connectorData)) {
|
|
183
|
+
if (!Array.isArray(items)) continue
|
|
184
|
+
|
|
185
|
+
// Skip bloblang functions/methods (they don't have config fields)
|
|
186
|
+
if (type === 'bloblang-functions' || type === 'bloblang-methods') continue
|
|
187
|
+
|
|
188
|
+
for (const item of items) {
|
|
189
|
+
if (!item.name) continue
|
|
190
|
+
|
|
191
|
+
// Only generate if there are fields
|
|
192
|
+
const hasFields = (item.config && item.config.children && item.config.children.length > 0) ||
|
|
193
|
+
(item.children && item.children.length > 0)
|
|
194
|
+
|
|
195
|
+
if (!hasFields) continue
|
|
196
|
+
|
|
197
|
+
const fields = item.config?.children || item.children || []
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// Use Handlebars template to render content
|
|
201
|
+
const content = fieldOnlyTemplate({
|
|
202
|
+
name: item.name,
|
|
203
|
+
children: fields
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const typeDir = type.endsWith('s') ? type : `${type}s`
|
|
207
|
+
const relative = `fields/${typeDir}/${item.name}.adoc`
|
|
208
|
+
|
|
209
|
+
// Get origin from first existing page in component (for git metadata)
|
|
210
|
+
const existingPages = contentCatalog.getPages((page) => page.src.component === 'redpanda-connect')
|
|
211
|
+
const origin = existingPages.length > 0 ? existingPages[0].src.origin : { type: 'generated' }
|
|
212
|
+
|
|
213
|
+
// Create a fake absolute path for generated files (used by logger)
|
|
214
|
+
const fakeAbspath = path.join(os.tmpdir(), 'generated-fields-only', relative)
|
|
215
|
+
|
|
216
|
+
// Create a stat object like real files have
|
|
217
|
+
const contentBuffer = Buffer.from(content)
|
|
218
|
+
const stat = Object.assign(new fs.Stats(), {
|
|
219
|
+
mode: 0o100644,
|
|
220
|
+
mtime: new Date(),
|
|
221
|
+
size: contentBuffer.byteLength
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Create file spec with all required properties
|
|
225
|
+
const file = contentCatalog.addFile({
|
|
226
|
+
path: `modules/components/pages/${relative}`,
|
|
227
|
+
contents: contentBuffer,
|
|
228
|
+
stat: stat,
|
|
229
|
+
src: {
|
|
230
|
+
component: 'redpanda-connect',
|
|
231
|
+
version: componentVersion.version,
|
|
232
|
+
module: 'components',
|
|
233
|
+
family: 'page',
|
|
234
|
+
relative: relative,
|
|
235
|
+
mediaType: 'text/asciidoc',
|
|
236
|
+
origin: origin,
|
|
237
|
+
abspath: fakeAbspath // Needed by logger for error messages
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Mark as field-only page (used by convert-to-markdown and add-llms-directive to skip directive)
|
|
242
|
+
file.isFieldOnlyPage = true
|
|
243
|
+
|
|
244
|
+
pagesGenerated++
|
|
245
|
+
} catch (err) {
|
|
246
|
+
logger.error(`Failed to generate field-only page for ${type}/${item.name}: ${err.message}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
logger.info(`Generated ${pagesGenerated} field-only pages`)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// Unpublish field-only pages as HTML (they should only exist as markdown)
|
|
255
|
+
// Do this in beforePublish so pages go through full processing (composition, markdown conversion)
|
|
256
|
+
// but don't get written as HTML files
|
|
257
|
+
this.on('beforePublish', ({ contentCatalog }) => {
|
|
258
|
+
const fieldOnlyPages = contentCatalog.getPages((page) => page.isFieldOnlyPage === true && page.out)
|
|
259
|
+
fieldOnlyPages.forEach((page) => {
|
|
260
|
+
delete page.out
|
|
261
|
+
})
|
|
262
|
+
if (fieldOnlyPages.length > 0) {
|
|
263
|
+
logger.debug(`Unpublished ${fieldOnlyPages.length} field-only pages from HTML output`)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.17.0",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"./extensions/generate-rp-connect-categories": "./extensions/generate-rp-connect-categories.js",
|
|
59
59
|
"./extensions/generate-index-data": "./extensions/generate-index-data.js",
|
|
60
60
|
"./extensions/generate-rp-connect-info": "./extensions/generate-rp-connect-info.js",
|
|
61
|
+
"./extensions/generate-fields-only-pages": "./extensions/generate-fields-only-pages.js",
|
|
61
62
|
"./extensions/add-global-attributes": "./extensions/add-global-attributes.js",
|
|
62
63
|
"./extensions/git-full-clone": "./extensions/git-full-clone.js",
|
|
63
64
|
"./extensions/add-git-dates": "./extensions/add-git-dates.js",
|
|
@@ -321,7 +321,7 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
321
321
|
// Compile the "main" template (used when writeFullDrafts = true)
|
|
322
322
|
const compiledTemplate = handlebars.compile(fs.readFileSync(template, 'utf8'));
|
|
323
323
|
|
|
324
|
-
// Determine which templates to use for
|
|
324
|
+
// Determine which templates to use for "fields" and "examples"
|
|
325
325
|
// If templateFields is not provided, fall back to the single `template`.
|
|
326
326
|
// If templateExamples is not provided, skip examples entirely.
|
|
327
327
|
const fieldsTemplatePath = templateFields || template;
|
|
@@ -367,6 +367,10 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
367
367
|
if (!item.name) continue;
|
|
368
368
|
const name = item.name;
|
|
369
369
|
|
|
370
|
+
// Compute typeDir once for this item (used in templates and file paths)
|
|
371
|
+
const typeDir = type.endsWith('s') ? type : `${type}s`;
|
|
372
|
+
item.typeDir = typeDir;
|
|
373
|
+
|
|
370
374
|
// Always generate field and example partials (needed for both standalone and draft modes)
|
|
371
375
|
// Render fields using the registered "fields" partial
|
|
372
376
|
const fieldsOut = handlebars
|
|
@@ -430,6 +434,8 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
430
434
|
item.support = csvData.support;
|
|
431
435
|
}
|
|
432
436
|
|
|
437
|
+
// typeDir is already computed at the beginning of the loop
|
|
438
|
+
|
|
433
439
|
let content;
|
|
434
440
|
try {
|
|
435
441
|
content = compiledTemplate(item);
|
|
@@ -439,7 +445,6 @@ async function generateRpcnConnectorDocs(options) {
|
|
|
439
445
|
|
|
440
446
|
// Determine output location based on availability
|
|
441
447
|
let destFile;
|
|
442
|
-
const typeDir = type.endsWith('s') ? type : `${type}s`;
|
|
443
448
|
|
|
444
449
|
if (isCloudOnly) {
|
|
445
450
|
// Cloud-only connectors go to partials/components/cloud-only/{type}s/{name}.adoc
|
|
@@ -28,6 +28,7 @@ function capToTwoSentences (description) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const abbreviations = [
|
|
31
|
+
/https?:\/\/[^\s]+?(?=[.!?](?:\s|$)|\s|$)/gi, // Protect URLs from being split by sentence detection (non-greedy, preserve trailing punctuation)
|
|
31
32
|
/\bv\d+\.\d+(?:\.\d+)?/gi,
|
|
32
33
|
/\d+\.\d+/g,
|
|
33
34
|
/\be\.g\./gi,
|
|
@@ -1127,9 +1128,11 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1127
1128
|
if (cgoConn.type === type) {
|
|
1128
1129
|
const exists = connectorData[type].some(c => c.name === cgoConn.name)
|
|
1129
1130
|
if (!exists) {
|
|
1131
|
+
// Singularize type for consistency with stored data (except "metrics" which stays plural)
|
|
1132
|
+
const componentType = cgoConn.type === 'metrics' ? 'metrics' : cgoConn.type.replace(/s$/, '')
|
|
1130
1133
|
connectorData[type].push({
|
|
1131
1134
|
...cgoConn,
|
|
1132
|
-
type:
|
|
1135
|
+
type: componentType,
|
|
1133
1136
|
cloudSupported: false,
|
|
1134
1137
|
requiresCgo: true
|
|
1135
1138
|
})
|
|
@@ -1144,9 +1147,11 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1144
1147
|
if (cloudConn.type === type) {
|
|
1145
1148
|
const exists = connectorData[type].some(c => c.name === cloudConn.name)
|
|
1146
1149
|
if (!exists) {
|
|
1150
|
+
// Singularize type for consistency with stored data (except "metrics" which stays plural)
|
|
1151
|
+
const componentType = cloudConn.type === 'metrics' ? 'metrics' : cloudConn.type.replace(/s$/, '')
|
|
1147
1152
|
connectorData[type].push({
|
|
1148
1153
|
...cloudConn,
|
|
1149
|
-
type:
|
|
1154
|
+
type: componentType,
|
|
1150
1155
|
cloudSupported: true,
|
|
1151
1156
|
requiresCgo: false,
|
|
1152
1157
|
cloudOnly: true
|
|
@@ -1368,8 +1373,14 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1368
1373
|
return !wasInOldOss && !wasInOldCgo && !docsExist
|
|
1369
1374
|
})
|
|
1370
1375
|
} else {
|
|
1376
|
+
// Fallback when oldBinaryAnalysis is unavailable
|
|
1377
|
+
// NOTE: oldIndex is loaded from saved JSON files that have platform metadata stripped
|
|
1378
|
+
// (see stripPlatformMetadata() call before saving). This means checking requiresCgo === true
|
|
1379
|
+
// will always fail. We rely on the name match and docs existence check as a heuristic.
|
|
1380
|
+
// If a component with the same name exists in oldIndex OR docs exist, treat it as existing.
|
|
1371
1381
|
newCgoComponents = binaryAnalysis.cgoOnly.filter(cgoComp => {
|
|
1372
|
-
|
|
1382
|
+
// Check if component with same name existed in old index (metadata unavailable, just name match)
|
|
1383
|
+
const wasInOldIndex = oldIndex[cgoComp.type]?.some(c => c.name === cgoComp.name)
|
|
1373
1384
|
|
|
1374
1385
|
// Check if docs already exist
|
|
1375
1386
|
const typePlural = cgoComp.type.endsWith('s') ? cgoComp.type : `${cgoComp.type}s`
|
|
@@ -1378,7 +1389,8 @@ async function handleRpcnConnectorDocs (options) {
|
|
|
1378
1389
|
fs.existsSync(path.join(root, relPath))
|
|
1379
1390
|
)
|
|
1380
1391
|
|
|
1381
|
-
|
|
1392
|
+
// Only treat as new if it wasn't in the old index AND docs don't exist
|
|
1393
|
+
return !wasInOldIndex && !docsExist
|
|
1382
1394
|
})
|
|
1383
1395
|
if (newCgoComponents.length > 0) {
|
|
1384
1396
|
console.log(` ℹ️ No old binary analysis found - treating ${newCgoComponents.length} cgo components not in old OSS data as new`)
|
|
@@ -17,7 +17,7 @@ Common::
|
|
|
17
17
|
--
|
|
18
18
|
|
|
19
19
|
```yml
|
|
20
|
-
include::components:example$common/{{
|
|
20
|
+
include::components:example$common/{{typeDir}}/{{name}}.yaml[]
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
--
|
|
@@ -26,7 +26,7 @@ Advanced::
|
|
|
26
26
|
--
|
|
27
27
|
|
|
28
28
|
```yml
|
|
29
|
-
include::components:example$advanced/{{
|
|
29
|
+
include::components:example$advanced/{{typeDir}}/{{name}}.yaml[]
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
--
|
|
@@ -47,11 +47,11 @@ You can either xref:install:prebuilt-binary.adoc[download a prebuilt cgo-enabled
|
|
|
47
47
|
|
|
48
48
|
{{/if}}
|
|
49
49
|
{{#if (or this.config.children children)}}
|
|
50
|
-
include::redpanda-connect:components:partial$fields/{{
|
|
50
|
+
include::redpanda-connect:components:partial$fields/{{typeDir}}/{{name}}.adoc[]
|
|
51
51
|
{{/if}}
|
|
52
52
|
|
|
53
53
|
{{#if this.examples}}
|
|
54
|
-
include::redpanda-connect:components:partial$examples/{{
|
|
54
|
+
include::redpanda-connect:components:partial$examples/{{typeDir}}/{{name}}.adoc[]
|
|
55
55
|
{{/if}}
|
|
56
56
|
|
|
57
57
|
|