@redpanda-data/docs-extensions-and-macros 4.16.4 → 4.17.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.
@@ -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
- return getAntoraStructure(repoRoot);
45
+ result = getAntoraStructure(repoRoot);
46
+ break;
45
47
 
46
48
  case 'get_redpanda_version':
47
- return getRedpandaVersion(args);
49
+ result = getRedpandaVersion(args);
50
+ break;
48
51
 
49
52
  case 'get_console_version':
50
- return getConsoleVersion();
53
+ result = getConsoleVersion();
54
+ break;
51
55
 
52
56
  case 'generate_property_docs':
53
- return generatePropertyDocs(args);
57
+ result = generatePropertyDocs(args);
58
+ break;
54
59
 
55
60
  case 'generate_metrics_docs':
56
- return generateMetricsDocs(args);
61
+ result = generateMetricsDocs(args);
62
+ break;
57
63
 
58
64
  case 'generate_rpk_docs':
59
- return generateRpkDocs(args);
65
+ result = generateRpkDocs(args);
66
+ break;
60
67
 
61
68
  case 'generate_rpcn_connector_docs':
62
- return generateRpConnectDocs(args);
69
+ result = generateRpConnectDocs(args);
70
+ break;
63
71
 
64
72
  case 'generate_helm_docs':
65
- return generateHelmDocs(args);
73
+ result = generateHelmDocs(args);
74
+ break;
66
75
 
67
76
  case 'generate_cloud_regions':
68
- return generateCloudRegions(args);
77
+ result = generateCloudRegions(args);
78
+ break;
69
79
 
70
80
  case 'generate_crd_docs':
71
- return generateCrdDocs(args);
81
+ result = generateCrdDocs(args);
82
+ break;
72
83
 
73
84
  case 'generate_bundle_openapi':
74
- return generateBundleOpenApi(args);
85
+ result = generateBundleOpenApi(args);
86
+ break;
75
87
 
76
88
  case 'review_generated_docs':
77
- return reviewGeneratedDocs(args);
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
- return {
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
- return {
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
- return {
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
- return {
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
- return {
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
 
@@ -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
  /**
@@ -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.replace(/^>\s*/, '')
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
- // Generate YAML frontmatter from AsciiDoc attributes
489
- const frontmatter = generateFrontmatter(page)
490
- if (frontmatter) {
491
- logger.debug(`Generated frontmatter for ${page.src?.path}`)
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
- // Extract H1 heading if present (only at document start)
495
- const h1Match = markdown.match(/^(#\s+.+?)(\n|$)/)
496
- let h1Heading = ''
497
- let restOfMarkdown = markdown
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
- if (h1Match) {
500
- h1Heading = h1Match[0]
501
- restOfMarkdown = markdown.substring(h1Match[0].length).trimStart()
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
- // Structure: H1 llms.txt directive (blockquote) frontmatter → source → content
505
- // The directive must appear near the top for agent-friendly docs spec compliance
506
- if (canonicalUrl) {
507
- const componentName = page.src?.component || '';
508
- // Use markdown blockquote format for the directive (visible, can be hidden with CSS)
509
- const llmsDirective = formatLlmsDirective(componentName);
510
-
511
- markdown = `${h1Heading}\n${llmsDirective}\n\n${frontmatter}<!-- Source: ${canonicalUrl} -->\n\n${restOfMarkdown}`
512
- } else if (frontmatter) {
513
- // If no canonical URL but we have frontmatter, still add directive after H1
514
- const llmsDirective = formatLlmsDirective();
515
- markdown = `${h1Heading}\n${llmsDirective}\n\n${frontmatter}${restOfMarkdown}`
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,305 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+
7
+
8
+ const handlebars = require('handlebars')
9
+
10
+ // Import and register Redpanda Connect helpers
11
+ const helpers = require('../tools/redpanda-connect/helpers')
12
+ Object.entries(helpers).forEach(([name, fn]) => {
13
+ if (typeof fn === 'function') {
14
+ handlebars.registerHelper(name, fn)
15
+ }
16
+ })
17
+
18
+ // Register table format helper
19
+ handlebars.registerHelper('renderConnectFieldsTable', function (children) {
20
+ if (!children || !Array.isArray(children) || children.length === 0) {
21
+ return 'No configuration fields available.\n\n'
22
+ }
23
+
24
+ const rows = []
25
+
26
+ function collectFields (fieldsList, pathPrefix = '') {
27
+ if (!Array.isArray(fieldsList)) return
28
+
29
+ fieldsList.forEach(field => {
30
+ if (field.is_deprecated || !field.name) return
31
+
32
+ const isArray = field.kind === 'array'
33
+ const nameWithArray = (typeof field.name === 'string' && isArray && !field.name.endsWith('[]'))
34
+ ? `${field.name}[]`
35
+ : field.name
36
+ const currentPath = pathPrefix ? `${pathPrefix}.${nameWithArray}` : nameWithArray
37
+
38
+ // Normalize type
39
+ let displayType
40
+ const isArrayTitle = typeof field.name === 'string' && field.name.endsWith('[]')
41
+ if (isArrayTitle) {
42
+ displayType = 'array<object>'
43
+ } else if (field.type === 'string' && field.kind === 'array') {
44
+ displayType = 'array'
45
+ } else if (field.type === 'unknown' && field.kind === 'map') {
46
+ displayType = 'object'
47
+ } else if (field.type === 'unknown' && (field.kind === 'array' || field.kind === 'list')) {
48
+ displayType = 'array'
49
+ } else {
50
+ displayType = field.type
51
+ }
52
+
53
+ // Format default value
54
+ let defaultValue = ''
55
+ if (field.default !== undefined) {
56
+ if (Array.isArray(field.default) && field.default.length === 0) {
57
+ defaultValue = '`[]`'
58
+ } else if (
59
+ field.default !== null &&
60
+ typeof field.default === 'object' &&
61
+ !Array.isArray(field.default) &&
62
+ Object.keys(field.default).length === 0
63
+ ) {
64
+ defaultValue = '`{}`'
65
+ } else if (typeof field.default === 'string') {
66
+ const escaped = field.default.replace(/`/g, '\\`')
67
+ defaultValue = `\`${escaped}\``
68
+ } else if (typeof field.default === 'number' || typeof field.default === 'boolean') {
69
+ defaultValue = `\`${field.default}\``
70
+ } else if (field.default === null) {
71
+ defaultValue = '`null`'
72
+ } else {
73
+ defaultValue = '_(complex)_'
74
+ }
75
+ }
76
+
77
+ // Clean description for table (single line)
78
+ let desc = (field.description || '').replace(/\n+/g, ' ').trim()
79
+ if (desc.length > 150) {
80
+ desc = desc.substring(0, 147) + '...'
81
+ }
82
+
83
+ rows.push({
84
+ field: `\`${currentPath}\``,
85
+ type: `\`${displayType}\``,
86
+ default: defaultValue || '-',
87
+ description: desc || '-'
88
+ })
89
+
90
+ // Recurse for children
91
+ if (field.children && Array.isArray(field.children) && field.children.length > 0) {
92
+ collectFields(field.children, currentPath)
93
+ }
94
+ })
95
+ }
96
+
97
+ collectFields(children, '')
98
+
99
+ if (rows.length === 0) return 'No configuration fields available.\n\n'
100
+
101
+ let table = '[cols="2,1,1,4"]\n'
102
+ table += '|===\n'
103
+ table += '|Field |Type |Default |Description\n\n'
104
+
105
+ rows.forEach(row => {
106
+ table += `|${row.field}\n`
107
+ table += `|${row.type}\n`
108
+ table += `|${row.default}\n`
109
+ table += `|${row.description}\n\n`
110
+ })
111
+
112
+ table += '|===\n'
113
+
114
+ return new handlebars.SafeString(table)
115
+ })
116
+
117
+ // Default configuration
118
+ const DEFAULTS = {
119
+ format: 'nested', // 'nested' or 'table'
120
+ enabled: true // Allow disabling the extension
121
+ }
122
+
123
+ module.exports.register = function ({ config }) {
124
+ const logger = this.getLogger('generate-fields-only-pages-extension')
125
+
126
+ // Merge config with defaults
127
+ const {
128
+ format = DEFAULTS.format,
129
+ enabled = DEFAULTS.enabled
130
+ } = config || {}
131
+
132
+ if (!enabled) {
133
+ logger.info('Extension disabled via config')
134
+ return
135
+ }
136
+
137
+ // Validate format
138
+ if (format !== 'nested' && format !== 'table') {
139
+ logger.error(`Invalid format '${format}'. Must be 'nested' or 'table'. Disabling extension.`)
140
+ return
141
+ }
142
+
143
+ // Compile template based on format (without title - these pages are meant to be included)
144
+ const helperName = format === 'table' ? 'renderConnectFieldsTable' : 'renderConnectFields'
145
+ const fieldOnlyTemplate = handlebars.compile(`{{{${helperName} children}}}`)
146
+
147
+ this.on('contentClassified', ({ contentCatalog, siteCatalog }) => {
148
+ const component = contentCatalog.getComponent('redpanda-connect')
149
+ if (!component) {
150
+ logger.warn('redpanda-connect component not found. Skipping field-only page generation.')
151
+ return
152
+ }
153
+
154
+ const componentVersion = component.latest
155
+ if (!componentVersion) {
156
+ logger.warn('No latest version found for redpanda-connect component.')
157
+ return
158
+ }
159
+
160
+ let connectorData
161
+ // Look for any versioned JSON attachment in the components module
162
+ // (i.e. modules/components/attachments/connect-X.Y.Z.json)
163
+ const attachments = contentCatalog.findBy({
164
+ component: 'redpanda-connect',
165
+ version: componentVersion.version,
166
+ module: 'components',
167
+ family: 'attachment'
168
+ })
169
+
170
+ // Find all versioned connector JSON attachments and sort by semver
171
+ const versionedAttachmentPattern = /^connect-(\d+)\.(\d+)\.(\d+)\.json$/
172
+ const matchingAttachments = attachments
173
+ .map((file) => {
174
+ const relative = file.src?.relative || ''
175
+ const basename = relative.split('/').pop()
176
+ const match = versionedAttachmentPattern.exec(basename)
177
+ if (match) {
178
+ return {
179
+ file,
180
+ version: {
181
+ major: parseInt(match[1], 10),
182
+ minor: parseInt(match[2], 10),
183
+ patch: parseInt(match[3], 10),
184
+ string: `${match[1]}.${match[2]}.${match[3]}`
185
+ }
186
+ }
187
+ }
188
+ return null
189
+ })
190
+ .filter(Boolean)
191
+ .sort((a, b) => {
192
+ // Sort by major, then minor, then patch (descending)
193
+ if (a.version.major !== b.version.major) return b.version.major - a.version.major
194
+ if (a.version.minor !== b.version.minor) return b.version.minor - a.version.minor
195
+ return b.version.patch - a.version.patch
196
+ })
197
+
198
+ if (matchingAttachments.length > 1) {
199
+ const versions = matchingAttachments.map(m => m.version.string).join(', ')
200
+ logger.warn(`Multiple versioned connector JSON attachments found (${versions}). Using highest version: ${matchingAttachments[0].version.string}`)
201
+ }
202
+
203
+ const attachment = matchingAttachments[0]?.file
204
+
205
+ if (!attachment) {
206
+ logger.warn('No JSON attachment found in the components module of the redpanda-connect content catalog. Skipping field-only page generation.')
207
+ return
208
+ }
209
+
210
+ try {
211
+ connectorData = JSON.parse(attachment.contents.toString('utf8'))
212
+ logger.info(`Loaded connector data from content catalog attachment: ${attachment.src?.relative || 'unknown'}`)
213
+ } catch (err) {
214
+ logger.error(`Failed to parse connector data from content catalog attachment: ${err.message}`)
215
+ return
216
+ }
217
+
218
+ let pagesGenerated = 0
219
+
220
+ // Get origin from first existing page in component (for git metadata)
221
+ const existingPages = contentCatalog.getPages((page) => page.src.component === 'redpanda-connect')
222
+ const origin = existingPages.length > 0 ? existingPages[0].src.origin : { type: 'generated' }
223
+
224
+ // Iterate over each type (inputs, outputs, processors, etc.)
225
+ for (const [type, items] of Object.entries(connectorData)) {
226
+ if (!Array.isArray(items)) continue
227
+
228
+ // Skip bloblang functions/methods (they don't have config fields)
229
+ if (type === 'bloblang-functions' || type === 'bloblang-methods') continue
230
+
231
+ for (const item of items) {
232
+ if (!item.name) continue
233
+
234
+ // Only generate if there are fields
235
+ const hasFields = (item.config && item.config.children && item.config.children.length > 0) ||
236
+ (item.children && item.children.length > 0)
237
+
238
+ if (!hasFields) continue
239
+
240
+ const fields = item.config?.children || item.children || []
241
+
242
+ try {
243
+ // Use Handlebars template to render content
244
+ const content = fieldOnlyTemplate({
245
+ name: item.name,
246
+ children: fields
247
+ })
248
+
249
+ const typeDir = type.endsWith('s') ? type : `${type}s`
250
+ const relative = `fields/${typeDir}/${item.name}.adoc`
251
+
252
+ // Create a fake absolute path for generated files (used by logger)
253
+ const fakeAbspath = path.join(os.tmpdir(), 'generated-fields-only', relative)
254
+
255
+ // Create a stat object like real files have
256
+ const contentBuffer = Buffer.from(content)
257
+ const stat = Object.assign(new fs.Stats(), {
258
+ mode: 0o100644,
259
+ mtime: new Date(),
260
+ size: contentBuffer.byteLength
261
+ })
262
+
263
+ // Create file spec with all required properties
264
+ const file = contentCatalog.addFile({
265
+ path: `modules/components/pages/${relative}`,
266
+ contents: contentBuffer,
267
+ stat: stat,
268
+ src: {
269
+ component: 'redpanda-connect',
270
+ version: componentVersion.version,
271
+ module: 'components',
272
+ family: 'page',
273
+ relative: relative,
274
+ mediaType: 'text/asciidoc',
275
+ origin: origin,
276
+ abspath: fakeAbspath // Needed by logger for error messages
277
+ }
278
+ })
279
+
280
+ // Mark as field-only page (used by convert-to-markdown and add-llms-directive to skip directive)
281
+ file.isFieldOnlyPage = true
282
+
283
+ pagesGenerated++
284
+ } catch (err) {
285
+ logger.error(`Failed to generate field-only page for ${type}/${item.name}: ${err.message}`)
286
+ }
287
+ }
288
+ }
289
+
290
+ logger.info(`Generated ${pagesGenerated} field-only pages`)
291
+ })
292
+
293
+ // Unpublish field-only pages as HTML (they should only exist as markdown)
294
+ // Do this in beforePublish so pages go through full processing (composition, markdown conversion)
295
+ // but don't get written as HTML files
296
+ this.on('beforePublish', ({ contentCatalog }) => {
297
+ const fieldOnlyPages = contentCatalog.getPages((page) => page.isFieldOnlyPage === true && page.out)
298
+ fieldOnlyPages.forEach((page) => {
299
+ delete page.out
300
+ })
301
+ if (fieldOnlyPages.length > 0) {
302
+ logger.debug(`Unpublished ${fieldOnlyPages.length} field-only pages from HTML output`)
303
+ }
304
+ })
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "4.16.4",
3
+ "version": "4.17.1",
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 fields and examples
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: cgoConn.type.replace(/s$/, ''),
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: cloudConn.type.replace(/s$/, ''),
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
- const wasInOldOss = oldIndex[cgoComp.type]?.some(c => c.name === cgoComp.name)
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
- return !wasInOldOss && !docsExist
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/{{type}}s/{{name}}.yaml[]
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/{{type}}s/{{name}}.yaml[]
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/{{type}}s/{{name}}.adoc[]
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/{{type}}s/{{name}}.adoc[]
54
+ include::redpanda-connect:components:partial$examples/{{typeDir}}/{{name}}.adoc[]
55
55
  {{/if}}
56
56
 
57
57