@redpanda-data/docs-extensions-and-macros 4.17.3 → 5.0.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.
Files changed (31) hide show
  1. package/extension-utils/llms-utils.js +2 -5
  2. package/extensions/algolia-indexer/generate-index.js +99 -54
  3. package/extensions/collect-bloblang-samples.js +6 -6
  4. package/extensions/compute-end-of-life.js +8 -4
  5. package/extensions/conditional-home-redirect.js +90 -0
  6. package/extensions/convert-to-markdown.js +23 -1
  7. package/extensions/find-related-docs.js +3 -3
  8. package/extensions/find-related-labs.js +1 -1
  9. package/extensions/generate-fields-only-pages.js +7 -7
  10. package/extensions/generate-rp-connect-categories.js +5 -5
  11. package/extensions/generate-rp-connect-info.js +6 -6
  12. package/extensions/modify-connect-tag-playbook.js +1 -1
  13. package/extensions/unified-navigation.js +669 -0
  14. package/extensions/unlisted-pages.js +5 -1
  15. package/extensions/unpublish-pages.js +12 -0
  16. package/extensions/version-fetcher/fetch-latest-docker-tag.js +7 -2
  17. package/extensions/version-fetcher/get-latest-connect.js +6 -2
  18. package/extensions/version-fetcher/get-latest-console-version.js +13 -4
  19. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +31 -5
  20. package/extensions/version-fetcher/get-latest-redpanda-version.js +8 -3
  21. package/extensions/version-fetcher/retry-util.js +10 -3
  22. package/extensions/version-fetcher/set-latest-version.js +9 -8
  23. package/macros/rp-connect-components.js +63 -1
  24. package/package.json +3 -1
  25. package/tools/redpanda-connect/AUTOMATION.md +5 -5
  26. package/tools/redpanda-connect/helpers/renderConnectFields.js +1 -1
  27. package/tools/redpanda-connect/pr-summary-formatter.js +4 -4
  28. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +133 -83
  29. package/tools/redpanda-connect/templates/bloblang-functions-overview.hbs +1 -1
  30. package/tools/redpanda-connect/templates/bloblang-methods-overview.hbs +1 -1
  31. package/tools/redpanda-connect/templates/connector.hbs +2 -2
@@ -0,0 +1,669 @@
1
+ 'use strict'
2
+
3
+ const yaml = require('js-yaml')
4
+
5
+ /**
6
+ * Unified Navigation Extension (Config-Driven)
7
+ *
8
+ * Creates a unified navigation structure based on component configuration.
9
+ * Components define their navigation hierarchy via the 'page-navigation' attribute in antora.yml.
10
+ * Supports nested buckets with version detection and breadcrumb hierarchy computation.
11
+ *
12
+ * Configuration: No playbook configuration needed - reads from component antora.yml files.
13
+ *
14
+ * Component antora.yml configuration:
15
+ * asciidoc:
16
+ * attributes:
17
+ * component-metadata:
18
+ * title: "Component Name"
19
+ * color: "#hexcolor"
20
+ * icon: "tabler-icon-name"
21
+ * order: 10
22
+ * page-navigation:
23
+ * - component-name
24
+ * - parent-component:
25
+ * - child-component-1
26
+ * - child-component-2
27
+ *
28
+ * Output: Attaches to pages with page-navigation config:
29
+ * - 'page-custom-navigation' (JSON array of buckets)
30
+ * - 'page-has-custom-nav' (boolean)
31
+ * - 'page-is-umbrella-nav' (boolean)
32
+ * - 'page-breadcrumb-hierarchy' (JSON array of breadcrumb items)
33
+ *
34
+ * Components without page-navigation use standard Antora navigation.
35
+ */
36
+
37
+ module.exports.register = function () {
38
+ const logger = this.getLogger('unified-navigation-extension')
39
+
40
+ this.on('navigationBuilt', ({ contentCatalog }) => {
41
+ try {
42
+ // Build a set of published page URLs (pages that have page.out defined)
43
+ const allPages = contentCatalog.getPages()
44
+ const publishedUrlsSet = new Set(
45
+ allPages
46
+ .filter((page) => page.out && page.pub && page.pub.url)
47
+ .map((page) => page.pub.url)
48
+ )
49
+
50
+ const components = contentCatalog.getComponents()
51
+
52
+ // Build component version navigation map (used by both config-driven and section-based nav)
53
+ const componentVersionNavMap = buildComponentVersionNavMap(components)
54
+
55
+ // PHASE 1: Config-Driven Navigation
56
+ // Step 1: Collect all components with page-navigation config
57
+ const pages = contentCatalog.getPages()
58
+ const configDrivenPages = new Set()
59
+ const componentConfigs = new Map() // component -> {configTree, configOwner}
60
+
61
+ // First pass: find components with page-navigation config
62
+ for (const component of components) {
63
+ for (const version of component.versions) {
64
+ const navConfig = version.asciidoc?.attributes?.['page-navigation']
65
+ if (navConfig) {
66
+ try {
67
+ const configTree = parseNavigationConfig(navConfig)
68
+ if (configTree.length > 0) {
69
+ // Store config and extract all component names mentioned
70
+ const allComponentsInConfig = new Set()
71
+ const extractComponents = (tree) => {
72
+ for (const item of tree) {
73
+ allComponentsInConfig.add(item.name)
74
+ if (item.children) {
75
+ extractComponents(item.children)
76
+ }
77
+ }
78
+ }
79
+ extractComponents(configTree)
80
+
81
+ // Store config for the owner
82
+ // A component's OWN config always takes priority (regardless of depth)
83
+ const existingOwnerConfig = componentConfigs.get(component.name)
84
+
85
+ if (!existingOwnerConfig || existingOwnerConfig.configOwner !== component.name) {
86
+ // No existing config, or existing config is from another component - use this one
87
+ componentConfigs.set(component.name, {
88
+ configTree,
89
+ configOwner: component.name,
90
+ allComponents: allComponentsInConfig,
91
+ })
92
+ } else {
93
+ // Existing config is also from this component - compare depths
94
+ const existingOwnerDepth = getComponentDepth(existingOwnerConfig.configTree, component.name)
95
+ const newOwnerDepth = getComponentDepth(configTree, component.name)
96
+ if (newOwnerDepth >= existingOwnerDepth) {
97
+ // New config has equal or better hierarchy, use it
98
+ componentConfigs.set(component.name, {
99
+ configTree,
100
+ configOwner: component.name,
101
+ allComponents: allComponentsInConfig,
102
+ })
103
+ }
104
+ }
105
+
106
+ // Also store this config for all child components
107
+ // Prefer configs with deeper nesting (more parent context for breadcrumbs)
108
+ for (const childComponent of allComponentsInConfig) {
109
+ const existingConfig = componentConfigs.get(childComponent)
110
+ const newDepth = getComponentDepth(configTree, childComponent)
111
+
112
+ if (!existingConfig) {
113
+ // No existing config, store this one
114
+ componentConfigs.set(childComponent, {
115
+ configTree,
116
+ configOwner: component.name,
117
+ allComponents: allComponentsInConfig,
118
+ })
119
+ } else {
120
+ // Compare depths - prefer deeper nesting (more parent context)
121
+ const existingDepth = getComponentDepth(existingConfig.configTree, childComponent)
122
+ if (newDepth > existingDepth) {
123
+ // New config has more parent context, prefer it for breadcrumbs
124
+ componentConfigs.set(childComponent, {
125
+ configTree,
126
+ configOwner: component.name,
127
+ allComponents: allComponentsInConfig,
128
+ })
129
+ }
130
+ }
131
+ }
132
+ }
133
+ } catch (error) {
134
+ logger.error(`Error parsing page-navigation for ${component.name}: ${error.message}`)
135
+ }
136
+ }
137
+ }
138
+ }
139
+
140
+ // Step 1.5: Find the home component's config for breadcrumb calculations
141
+ // Home has the full hierarchy, so use it for ALL breadcrumb computations
142
+ const homeConfig = componentConfigs.get('home')
143
+
144
+ // Step 2: Apply navigation to all pages of components mentioned in configs
145
+ for (const page of pages) {
146
+ if (!page.src) continue
147
+
148
+ const configData = componentConfigs.get(page.src.component)
149
+ if (!configData) continue // No config for this component
150
+
151
+ try {
152
+ // Filter config tree to show only relevant hierarchy for current page
153
+ const relevantTree = filterConfigTreeForComponent(configData.configTree, page.src.component)
154
+
155
+ // Check if this component has children and owns the config
156
+ const hasChildComponents = componentHasChildren(configData.configTree, page.src.component)
157
+ const ownsConfig = configData.configOwner === page.src.component
158
+
159
+ // If component has children and owns config, we'll show its nav items outside buckets
160
+ // So build buckets only for children, not for the parent itself
161
+ let treeForBuckets = relevantTree
162
+ if (hasChildComponents && ownsConfig) {
163
+ // Extract children from ORIGINAL configTree (not filtered relevantTree)
164
+ // because filterConfigTreeForComponent sets children: undefined on parent
165
+ const parentNode = findNodeInTree(configData.configTree, page.src.component)
166
+ if (parentNode && parentNode.children) {
167
+ treeForBuckets = parentNode.children
168
+ } else {
169
+ logger.warn(`${page.src.component}: Has children but couldn't find them in configTree`)
170
+ }
171
+ }
172
+
173
+ const buckets = buildBucketsFromConfig(
174
+ treeForBuckets,
175
+ contentCatalog,
176
+ page.src.component,
177
+ page.src.version,
178
+ publishedUrlsSet,
179
+ componentVersionNavMap,
180
+ page
181
+ )
182
+
183
+ // Always compute breadcrumb hierarchy from HOME config (which has full hierarchy)
184
+ // This ensures components like self-managed show "Docs > Data Platform > Self-Managed"
185
+ // instead of just "Docs > Self-Managed"
186
+ page.asciidoc = page.asciidoc || {}
187
+ page.asciidoc.attributes = page.asciidoc.attributes || {}
188
+
189
+ if (homeConfig) {
190
+ const hierarchy = findComponentPath(homeConfig.configTree, page.src.component, contentCatalog)
191
+ if (hierarchy) {
192
+ page.asciidoc.attributes['page-breadcrumb-hierarchy'] = JSON.stringify(hierarchy)
193
+ }
194
+ }
195
+
196
+ // Only set custom navigation if buckets exist AND component is not standalone
197
+ // Standalone components appear at root level with no children (e.g., agentic-data-plane)
198
+ // They appear in product switcher/home nav but use standard Antora nav on their own pages
199
+ const isStandaloneComponent = isStandalone(configData.configTree, page.src.component)
200
+
201
+ if (buckets.length > 0 && !isStandaloneComponent) {
202
+ // Build full navigation array
203
+ let fullNavigation = []
204
+
205
+ // If component has children and owns config, show its nav items outside buckets
206
+ if (hasChildComponents && ownsConfig) {
207
+ // Get the component's own nav items (from nav.adoc) to prepend before child buckets
208
+ const compData = componentVersionNavMap.get(page.src.component)
209
+ let ownNavItems = []
210
+ if (compData) {
211
+ const versionData = compData.versionMap.get(page.src.version)
212
+ if (versionData && versionData.navigation) {
213
+ ownNavItems = filterUnpublishedPages(versionData.navigation, publishedUrlsSet)
214
+ }
215
+ }
216
+
217
+ // If parent component has its own nav items, wrap them in a pseudo-bucket with showNavItemsOnly
218
+ // This renders the items directly without bucket header/wrapper
219
+ if (ownNavItems.length > 0) {
220
+ fullNavigation.push({
221
+ items: ownNavItems,
222
+ showNavItemsOnly: true,
223
+ isCurrentBucket: true,
224
+ })
225
+ }
226
+ }
227
+
228
+ // Add child component buckets
229
+ fullNavigation = fullNavigation.concat(buckets)
230
+
231
+ page.asciidoc.attributes['page-custom-navigation'] = JSON.stringify(fullNavigation)
232
+ page.asciidoc.attributes['page-has-custom-nav'] = 'true'
233
+ // Set is-umbrella-nav to show parent bucket headers
234
+ page.asciidoc.attributes['page-is-umbrella-nav'] = 'true'
235
+ }
236
+
237
+ configDrivenPages.add(page.src.component)
238
+ } catch (error) {
239
+ logger.error(`Error processing navigation for ${page.src.path}: ${error.message}`)
240
+ }
241
+ }
242
+
243
+ if (configDrivenPages.size > 0) {
244
+ logger.info(`Processed config-driven navigation for ${configDrivenPages.size} components: ${Array.from(configDrivenPages).join(', ')}`)
245
+ }
246
+ } catch (error) {
247
+ logger.error(`Error building unified navigation: ${error.message}`)
248
+ logger.error(error.stack)
249
+ }
250
+ })
251
+ }
252
+
253
+ /**
254
+ * Build a map of component navigation data for quick lookup
255
+ * @param {Array} components - Array of component objects from content catalog
256
+ * @returns {Map} Map of component name -> { component, versionMap, latestVersion }
257
+ */
258
+ function buildComponentVersionNavMap(components) {
259
+ const map = new Map()
260
+
261
+ for (const component of components) {
262
+ const versionMap = new Map()
263
+ for (const version of component.versions) {
264
+ versionMap.set(version.version, {
265
+ navigation: version.navigation || [],
266
+ displayVersion: version.displayVersion || version.version,
267
+ })
268
+ }
269
+
270
+ map.set(component.name, {
271
+ component,
272
+ versionMap,
273
+ latestVersion: component.latestVersion || component.versions[0],
274
+ })
275
+ }
276
+
277
+ return map
278
+ }
279
+
280
+ /**
281
+ * Filter navigation items to exclude unpublished pages
282
+ * @param {Array} items - Navigation items array
283
+ * @param {Map} publishedUrlsSet - Set of published page URLs
284
+ * @returns {Array} Filtered navigation items
285
+ */
286
+ function filterUnpublishedPages(items, publishedUrlsSet) {
287
+ if (!Array.isArray(items)) return []
288
+
289
+ return items
290
+ .map((item) => {
291
+ // If item has a URL, check if it's in the published URLs set
292
+ if (item.url) {
293
+ // Skip items whose pages aren't published
294
+ if (!publishedUrlsSet.has(item.url)) {
295
+ return null
296
+ }
297
+ }
298
+
299
+ // Return a new object with filtered children to avoid mutating the original
300
+ if (item.items && Array.isArray(item.items)) {
301
+ return { ...item, items: filterUnpublishedPages(item.items, publishedUrlsSet) }
302
+ }
303
+
304
+ return { ...item }
305
+ })
306
+ .filter(Boolean) // Remove null entries
307
+ }
308
+
309
+ /**
310
+ * Extract component-metadata from a component version
311
+ * @param {Object} version - Component version object
312
+ * @returns {Object|null} The component-metadata object or null
313
+ */
314
+ function getHeaderData(version) {
315
+ if (!version || !version.asciidoc || !version.asciidoc.attributes) {
316
+ return null
317
+ }
318
+ return version.asciidoc.attributes['component-metadata'] || null
319
+ }
320
+
321
+ /**
322
+ * Find a node in the config tree by name
323
+ * @param {Array} tree - Navigation config tree
324
+ * @param {string} componentName - Component name to search for
325
+ * @returns {Object|null} The node or null if not found
326
+ */
327
+ function findNodeInTree(tree, componentName) {
328
+ if (!Array.isArray(tree)) return null
329
+
330
+ for (const item of tree) {
331
+ if (item.name === componentName) {
332
+ return item
333
+ }
334
+ if (item.children) {
335
+ const found = findNodeInTree(item.children, componentName)
336
+ if (found) return found
337
+ }
338
+ }
339
+ return null
340
+ }
341
+
342
+ /**
343
+ * Check if a component has children in the config tree
344
+ * @param {Array} tree - Navigation config tree
345
+ * @param {string} componentName - Component name to search for
346
+ * @returns {boolean} True if component has children
347
+ */
348
+ function componentHasChildren(tree, componentName) {
349
+ const node = findNodeInTree(tree, componentName)
350
+ return !!(node && node.children && node.children.length > 0)
351
+ }
352
+
353
+ /**
354
+ * Find the depth of a component in a config tree
355
+ * @param {Array} tree - Navigation config tree
356
+ * @param {string} targetComponent - Component name to find
357
+ * @param {number} currentDepth - Current depth in recursion
358
+ * @returns {number} Depth of component (0 = root level), or -1 if not found
359
+ */
360
+ function getComponentDepth(tree, targetComponent, currentDepth = 0) {
361
+ for (const item of tree) {
362
+ if (item.name === targetComponent) {
363
+ return currentDepth
364
+ }
365
+ if (item.children) {
366
+ const childDepth = getComponentDepth(item.children, targetComponent, currentDepth + 1)
367
+ if (childDepth !== -1) return childDepth
368
+ }
369
+ }
370
+ return -1 // Not found
371
+ }
372
+
373
+ /**
374
+ * Parse page-navigation YAML config into component tree structure
375
+ * @param {string|array} navConfig - YAML string or parsed array from page-navigation attribute
376
+ * @returns {Array} Array of bucket definitions with hierarchy
377
+ *
378
+ * Example input:
379
+ * - data-platform:
380
+ * - cloud-data-platform
381
+ * - self-managed: [streaming, connect]
382
+ * - redpanda-adp
383
+ *
384
+ * Example output:
385
+ * [{
386
+ * name: 'data-platform',
387
+ * children: [
388
+ * { name: 'cloud-data-platform' },
389
+ * { name: 'self-managed', children: [{ name: 'streaming' }, { name: 'connect' }] }
390
+ * ]
391
+ * }, { name: 'redpanda-adp' }]
392
+ */
393
+ function parseNavigationConfig(navConfig) {
394
+ // If it's already an array (from YAML parsing), use it directly
395
+ let config
396
+ if (typeof navConfig === 'string') {
397
+ try {
398
+ config = yaml.load(navConfig)
399
+ } catch (error) {
400
+ // Log YAML parsing error with context
401
+ console.error(`Failed to parse page-navigation YAML: ${error.message}`)
402
+ console.error(`YAML content: ${navConfig}`)
403
+ return []
404
+ }
405
+ } else {
406
+ config = navConfig
407
+ }
408
+
409
+ if (!Array.isArray(config)) {
410
+ return []
411
+ }
412
+
413
+ function parseItem(item) {
414
+ if (typeof item === 'string') {
415
+ // Simple component name
416
+ return { name: item }
417
+ } else if (typeof item === 'object' && item !== null) {
418
+ // Object with component name as key and children as value
419
+ const keys = Object.keys(item)
420
+ if (keys.length === 0) return null
421
+
422
+ const name = keys[0]
423
+ const childrenValue = item[name]
424
+
425
+ if (!childrenValue) {
426
+ return { name }
427
+ }
428
+
429
+ // Parse children (can be array of strings, objects, or mix)
430
+ const children = Array.isArray(childrenValue)
431
+ ? childrenValue.map(parseItem).filter(Boolean)
432
+ : [parseItem(childrenValue)].filter(Boolean)
433
+
434
+ return { name, children: children.length > 0 ? children : undefined }
435
+ }
436
+
437
+ return null
438
+ }
439
+
440
+ return config.map(parseItem).filter(Boolean)
441
+ }
442
+
443
+ /**
444
+ * Build navigation buckets from parsed config tree
445
+ * @param {Array} configTree - Parsed navigation config tree
446
+ * @param {Object} contentCatalog - Antora content catalog
447
+ * @param {string} currentPageComponent - Current page's component name
448
+ * @param {string} currentPageVersion - Current page's version
449
+ * @param {Set} publishedUrlsSet - Set of published page URLs
450
+ * @param {Map} componentVersionNavMap - Map of component navigation data
451
+ * @returns {Array} Array of bucket objects for templates
452
+ */
453
+ /**
454
+ * Check if a component is standalone (root-level with no children)
455
+ * Standalone components appear in product switcher but use standard Antora nav
456
+ * @param {Array} configTree - Parsed navigation config tree
457
+ * @param {string} componentName - Component name to check
458
+ * @returns {boolean} True if component is standalone
459
+ */
460
+ function isStandalone(configTree, componentName) {
461
+ for (const item of configTree) {
462
+ if (item.name === componentName) {
463
+ // Component found at root level
464
+ return !item.children || item.children.length === 0
465
+ }
466
+ }
467
+ return false
468
+ }
469
+
470
+ /**
471
+ * Filter config tree to show only relevant hierarchy for a component
472
+ * - If component is at root level: return entire tree
473
+ * - If component is a parent (has children): return that parent with its children
474
+ * - If component is a child (leaf): return parent + siblings (parent marked for nav-only display)
475
+ * @param {Array} configTree - Full navigation config tree
476
+ * @param {string} componentName - Current page component
477
+ * @returns {Array} Filtered config tree
478
+ */
479
+ function filterConfigTreeForComponent(configTree, componentName) {
480
+ // Check if component is at root level
481
+ for (const item of configTree) {
482
+ if (item.name === componentName) {
483
+ // Component is at root - if it has children, show parent nav items + children as buckets
484
+ if (item.children) {
485
+ // Return parent with showNavItemsOnly flag + children as separate buckets
486
+ return [{ ...item, showNavItemsOnly: true, children: undefined }, ...item.children]
487
+ }
488
+ return configTree
489
+ }
490
+ }
491
+
492
+ // Component is nested - find it and determine if it's a parent or child
493
+ function findComponentAndParent(items, parent = null) {
494
+ for (const item of items) {
495
+ if (item.name === componentName) {
496
+ // Found the component
497
+ if (item.children) {
498
+ // Component is a parent - return it with its children
499
+ return { isParent: true, parent: null, result: [item] }
500
+ } else {
501
+ // Component is a leaf child
502
+ // Check if there are other leaf siblings (to determine if we should show siblings)
503
+ const siblings = parent ? parent.children.filter(child => child.name !== componentName) : []
504
+ const hasLeafSiblings = siblings.some(sibling => !sibling.children)
505
+
506
+ if (hasLeafSiblings) {
507
+ // Has other leaf siblings - show parent nav + all siblings (like Streaming/Connect)
508
+ // Mark parent as "showNavItemsOnly" so it doesn't render as a bucket header
509
+ return { isParent: false, parent: parent, result: parent ? [{ ...parent, showNavItemsOnly: true }, ...parent.children] : [item] }
510
+ } else {
511
+ // No leaf siblings (standalone like Cloud) - return empty to use standard nav
512
+ return { isParent: false, parent: null, result: [] }
513
+ }
514
+ }
515
+ }
516
+
517
+ if (item.children) {
518
+ const found = findComponentAndParent(item.children, item)
519
+ if (found) return found
520
+ }
521
+ }
522
+ return null
523
+ }
524
+
525
+ const found = findComponentAndParent(configTree)
526
+ return found ? found.result : configTree // Fallback to full tree if not found
527
+ }
528
+
529
+ function buildBucketsFromConfig(
530
+ configTree,
531
+ contentCatalog,
532
+ currentPageComponent,
533
+ currentPageVersion,
534
+ publishedUrlsSet,
535
+ componentVersionNavMap,
536
+ page
537
+ ) {
538
+ // Get list of buckets to expand by default from page attribute
539
+ const expandBucketsAttr = page?.asciidoc?.attributes?.['page-expand-buckets'] || ''
540
+ const expandBuckets = expandBucketsAttr.split(',').map((s) => s.trim()).filter(Boolean)
541
+
542
+ function buildBucket(configItem) {
543
+ const componentName = configItem.name
544
+ const component = contentCatalog.getComponent(componentName)
545
+
546
+ if (!component) {
547
+ return null // Component not found
548
+ }
549
+
550
+ const latestVersion = component.latestVersion || component.versions[0]
551
+ if (!latestVersion) {
552
+ return null
553
+ }
554
+
555
+ // Get metadata from component-metadata
556
+ const metadata = getHeaderData(latestVersion)
557
+ if (!metadata) {
558
+ return null
559
+ }
560
+
561
+ // Build versions array
562
+ const versions = component.versions.map((v) => ({
563
+ version: v.version,
564
+ displayVersion: v.displayVersion || v.version,
565
+ url: v.url,
566
+ isPrerelease: !!v.prerelease,
567
+ releaseDate: v.asciidoc?.attributes?.['page-release-date'] || null,
568
+ isEol: v.asciidoc?.attributes?.['page-is-past-eol'] === 'true',
569
+ }))
570
+
571
+ // Determine if this is the current bucket
572
+ const isCurrentBucket = componentName === currentPageComponent
573
+
574
+ // Check if this bucket should be expanded by default (from page attribute)
575
+ const isExpandedByDefault = expandBuckets.includes(componentName)
576
+
577
+ // Get navigation for this component
578
+ let navigation, displayVersion
579
+ const compData = componentVersionNavMap.get(componentName)
580
+
581
+ if (isCurrentBucket && compData) {
582
+ // For current bucket, use page's version
583
+ const versionData = compData.versionMap.get(currentPageVersion)
584
+ if (versionData) {
585
+ navigation = versionData.navigation
586
+ displayVersion = versionData.displayVersion
587
+ } else {
588
+ navigation = compData.latestVersion?.navigation || []
589
+ displayVersion = compData.latestVersion?.displayVersion || compData.latestVersion?.version
590
+ }
591
+ } else if (compData) {
592
+ // For other buckets, use latest version
593
+ navigation = compData.latestVersion?.navigation || []
594
+ displayVersion = compData.latestVersion?.displayVersion || compData.latestVersion?.version
595
+ } else {
596
+ navigation = []
597
+ displayVersion = latestVersion.displayVersion || latestVersion.version
598
+ }
599
+
600
+ // Filter unpublished pages
601
+ const filteredNavigation = filterUnpublishedPages(navigation, publishedUrlsSet)
602
+
603
+ const bucket = {
604
+ componentName,
605
+ title: metadata.title || component.title || componentName,
606
+ color: metadata.color || '#6366f1',
607
+ icon: metadata.icon || null,
608
+ order: metadata.order || 999,
609
+ versions,
610
+ currentVersion: displayVersion,
611
+ isCurrentBucket,
612
+ isExpandedByDefault,
613
+ items: filteredNavigation,
614
+ componentUrl: component.url,
615
+ showNavItemsOnly: configItem.showNavItemsOnly || false, // Flag for rendering nav items without bucket header,
616
+ }
617
+
618
+ // Process children recursively
619
+ if (configItem.children && configItem.children.length > 0) {
620
+ bucket.children = configItem.children.map(buildBucket).filter(Boolean)
621
+ // Determine if parent should be expanded
622
+ bucket.hasCurrentChild = bucket.children.some((c) => c.isCurrentBucket || c.hasCurrentChild)
623
+ }
624
+
625
+ return bucket
626
+ }
627
+
628
+ return configTree.map(buildBucket).filter(Boolean)
629
+ }
630
+
631
+ /**
632
+ * Find component's position in navigation hierarchy for breadcrumbs
633
+ * @param {Array} configTree - Parsed navigation config tree
634
+ * @param {string} targetComponent - Component to find
635
+ * @param {Object} contentCatalog - Antora content catalog
636
+ * @returns {Array|null} Array of breadcrumb items or null
637
+ */
638
+ function findComponentPath(configTree, targetComponent, contentCatalog) {
639
+ function search(items, path = []) {
640
+ for (const item of items) {
641
+ const componentName = item.name
642
+ const component = contentCatalog.getComponent(componentName)
643
+
644
+ if (!component) continue
645
+
646
+ const latestVersion = component.latestVersion || component.versions[0]
647
+ const metadata = latestVersion ? getHeaderData(latestVersion) : null
648
+
649
+ const breadcrumbItem = {
650
+ component: componentName,
651
+ title: metadata?.title || component.title || componentName,
652
+ url: component.url || `/${componentName}/`,
653
+ }
654
+
655
+ if (componentName === targetComponent) {
656
+ return [...path, breadcrumbItem]
657
+ }
658
+
659
+ if (item.children && item.children.length > 0) {
660
+ const childPath = search(item.children, [...path, breadcrumbItem])
661
+ if (childPath) return childPath
662
+ }
663
+ }
664
+
665
+ return null
666
+ }
667
+
668
+ return search(configTree)
669
+ }
@@ -7,7 +7,7 @@ module.exports.register = function ({ config }) {
7
7
  this.on('navigationBuilt', ({ siteCatalog, contentCatalog }) => {
8
8
  contentCatalog.getComponents().forEach(({ versions }) => {
9
9
  versions.forEach(({ name: component, version, navigation: nav, url: defaultUrl }) => {
10
- if (component === 'api' || component === 'redpanda-labs') return;
10
+ if (component === 'api' || component === 'labs') return;
11
11
  if (!nav) return;
12
12
 
13
13
  const navEntriesByUrl = getNavEntriesByUrl(nav);
@@ -23,6 +23,10 @@ module.exports.register = function ({ config }) {
23
23
  // Skip field-only pages (generated for includes, not standalone navigation)
24
24
  if (page.isFieldOnlyPage) return collector;
25
25
 
26
+ // Skip technical pages that are intentionally unlisted (e.g., llms.txt, sitemap)
27
+ const technicalPages = ['llms', 'sitemap', 'sitemap-llms'];
28
+ if (technicalPages.includes(page.src.stem)) return collector;
29
+
26
30
  if (!(page.pub.url in navEntriesByUrl) && page.pub.url !== defaultUrl) {
27
31
  logger.warn({ file: page.src, source: page.src.origin }, 'detected unlisted page');
28
32
  return collector.concat(page);