@redpanda-data/docs-extensions-and-macros 5.7.0 → 5.8.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.
Files changed (41) hide show
  1. package/bin/doc-tools.js +15 -0
  2. package/extensions/add-faq-structured-data.js +3 -0
  3. package/extensions/add-git-dates.js +3 -0
  4. package/extensions/add-global-attributes.js +2 -0
  5. package/extensions/add-llms-directive.js +3 -0
  6. package/extensions/add-markdown-urls-to-sitemap.js +3 -0
  7. package/extensions/add-pages-to-root.js +2 -0
  8. package/extensions/aggregate-terms.js +2 -0
  9. package/extensions/algolia-indexer/index.js +2 -0
  10. package/extensions/archive-attachments.js +2 -0
  11. package/extensions/collect-bloblang-samples.js +2 -0
  12. package/extensions/compute-end-of-life.js +3 -0
  13. package/extensions/conditional-home-redirect.js +3 -0
  14. package/extensions/convert-llms-to-txt.js +3 -0
  15. package/extensions/convert-to-markdown.js +2 -0
  16. package/extensions/find-related-docs.js +3 -0
  17. package/extensions/find-related-labs.js +3 -0
  18. package/extensions/generate-fields-only-pages.js +3 -0
  19. package/extensions/generate-index-data.js +3 -0
  20. package/extensions/generate-rp-connect-categories.js +3 -0
  21. package/extensions/generate-rp-connect-info.js +3 -0
  22. package/extensions/git-full-clone.js +3 -0
  23. package/extensions/modify-connect-tag-playbook.js +3 -0
  24. package/extensions/process-context-switcher.js +3 -0
  25. package/extensions/produce-redirects.js +3 -0
  26. package/extensions/replace-attributes-in-attachments.js +3 -0
  27. package/extensions/resolve-xrefs-in-attachments.js +3 -0
  28. package/extensions/set-available-attachment-versions.js +3 -0
  29. package/extensions/unified-navigation.js +3 -0
  30. package/extensions/unlisted-pages.js +3 -0
  31. package/extensions/unpublish-pages.js +3 -0
  32. package/extensions/util/raise-listener-limit.js +28 -0
  33. package/extensions/validate-attributes.js +3 -0
  34. package/extensions/version-fetcher/set-latest-version.js +3 -0
  35. package/package.json +1 -1
  36. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +108 -2
  37. package/tools/redpanda-connect/templates/bloblang-functions-overview.hbs +2 -0
  38. package/tools/redpanda-connect/templates/bloblang-methods-overview.hbs +2 -0
  39. package/tools/redpanda-connect/templates/connector.hbs +13 -1
  40. package/tools/rpk-docs/generate-plugin-stubs.js +56 -9
  41. package/tools/rpk-docs/templates/command.hbs +23 -5
package/bin/doc-tools.js CHANGED
@@ -1660,6 +1660,21 @@ automation
1660
1660
  return match
1661
1661
  }
1662
1662
  })
1663
+ // Antora resolves page metadata with a header-only parse that stops at
1664
+ // the first blank line. The chart README templates emit :description:
1665
+ // below the title's blank line, so the page shipped the generic site
1666
+ // meta description despite carrying one (found on k-connect-helm-spec,
1667
+ // DOC-2414 investigation). Relocate the first :description: into the
1668
+ // header, directly under the title.
1669
+ const descMatch = doc.match(/^:description:[^\n]*$/m)
1670
+ if (descMatch) {
1671
+ const headerEnd = doc.indexOf('\n\n')
1672
+ if (headerEnd !== -1 && doc.indexOf(descMatch[0]) > headerEnd) {
1673
+ doc = doc.replace(descMatch[0] + '\n', '')
1674
+ doc = doc.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
1675
+ doc = doc.replace(/\n{3,}/g, '\n\n')
1676
+ }
1677
+ }
1663
1678
  fs.writeFileSync(outFile, doc, 'utf8')
1664
1679
 
1665
1680
  console.log(`Done: Wrote ${outFile}`)
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Generates FAQPage JSON-LD structured data for SEO.
5
7
  *
@@ -147,6 +149,7 @@ function generateFaqJsonLd(faqs, baseUrl) {
147
149
  }
148
150
 
149
151
  module.exports.register = function () {
152
+ raiseListenerLimit(this)
150
153
  const logger = this.getLogger('add-faq-structured-data-extension')
151
154
  let playbook
152
155
 
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Adds Git commit dates to pages as attributes.
5
7
  *
@@ -179,6 +181,7 @@ async function getTreeFiles (git, gitdir, oid, prefix, cache, treeCache) {
179
181
  }
180
182
 
181
183
  module.exports.register = function () {
184
+ raiseListenerLimit(this)
182
185
  const logger = this.getLogger('add-git-dates-extension')
183
186
  const context = this
184
187
 
@@ -1,3 +1,4 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  /* Example use in the playbook
2
3
  * antora:
3
4
  extensions:
@@ -12,6 +13,7 @@ const _ = require('lodash');
12
13
  const ATTRIBUTES_PATH = 'modules/ROOT/partials/'; // Default path within the 'shared' component
13
14
 
14
15
  module.exports.register = function ({ config }) {
16
+ raiseListenerLimit(this)
15
17
  const logger = this.getLogger('global-attributes-extension');
16
18
 
17
19
  /**
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Adds llms.txt directive to HTML pages for agent-friendly documentation.
5
7
  *
@@ -16,6 +18,7 @@
16
18
  const { formatLlmsDirective } = require('../extension-utils/llms-utils')
17
19
 
18
20
  module.exports.register = function () {
21
+ raiseListenerLimit(this)
19
22
  const logger = this.getLogger('add-llms-directive-extension')
20
23
 
21
24
  this.on('pagesComposed', ({ contentCatalog }) => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Adds markdown URL entries to sitemap.xml files for AI-friendly documentation.
5
7
  *
@@ -21,6 +23,7 @@ const { parseStringPromise } = require('xml2js')
21
23
  const { toMarkdownUrl } = require('../extension-utils/url-utils')
22
24
 
23
25
  module.exports.register = function () {
26
+ raiseListenerLimit(this)
24
27
  const logger = this.getLogger('add-markdown-urls-to-sitemap-extension')
25
28
 
26
29
  this.on('beforePublish', async ({ siteCatalog }) => {
@@ -1,4 +1,6 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  module.exports.register = function ({ config }) {
3
+ raiseListenerLimit(this)
2
4
  this.on('beforePublish', ({ siteCatalog, contentCatalog }) => {
3
5
  const logger = this.getLogger('add-pages-to-site-root');
4
6
  if (!config || !config.files || !config.files.length) {
@@ -1,3 +1,4 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  /* Example use in the playbook
2
3
  * antora:
3
4
  extensions:
@@ -10,6 +11,7 @@ const path = require('path');
10
11
  const TERMS_PATH = 'modules/terms/partials/'; // Default path within the 'shared' component
11
12
 
12
13
  module.exports.register = function ({ config }) {
14
+ raiseListenerLimit(this)
13
15
  const logger = this.getLogger('term-aggregation-extension');
14
16
 
15
17
  /**
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const generateIndex = require('./generate-index')
4
+ const { raiseListenerLimit } = require('../util/raise-listener-limit')
4
5
  const algoliasearch = require('algoliasearch')
5
6
  const http = require('http')
6
7
  const https = require('https')
@@ -21,6 +22,7 @@ function register ({
21
22
  ...unknownOptions
22
23
  }
23
24
  }) {
25
+ raiseListenerLimit(this)
24
26
  const logger = this.getLogger('algolia-indexer-extension')
25
27
 
26
28
  // Validate required environment variables
@@ -1,3 +1,4 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  "use strict";
2
3
 
3
4
  const fs = require("fs");
@@ -35,6 +36,7 @@ async function createTarInMemory(tempDir) {
35
36
  }
36
37
 
37
38
  module.exports.register = function ({ config }) {
39
+ raiseListenerLimit(this)
38
40
  const logger = this.getLogger("archive-attachments-extension");
39
41
  const archives = config.data?.archives || [];
40
42
 
@@ -1,6 +1,8 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  const yaml = require('js-yaml');
2
3
 
3
4
  module.exports.register = function () {
5
+ raiseListenerLimit(this)
4
6
  const logger = this.getLogger('collect-bloblang-samples');
5
7
 
6
8
  this.on('contentClassified', ({ contentCatalog }) => {
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const calculateEOL = require('./util/calculate-eol.js');
4
6
 
5
7
  module.exports.register = function ({ config }) {
8
+ raiseListenerLimit(this)
6
9
  this.on('contentClassified', ({ contentCatalog }) => {
7
10
  const logger = this.getLogger("compute-end-of-life-extension");
8
11
 
@@ -8,7 +8,10 @@
8
8
 
9
9
  'use strict'
10
10
 
11
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
12
+
11
13
  module.exports.register = function () {
14
+ raiseListenerLimit(this)
12
15
  const logger = this.getLogger('conditional-home-redirect')
13
16
  let shouldRedirect = false
14
17
 
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const { toMarkdownUrl } = require('../extension-utils/url-utils');
4
6
  const { stripMarkdownMetadata } = require('../extension-utils/llms-utils');
5
7
 
@@ -20,6 +22,7 @@ const { stripMarkdownMetadata } = require('../extension-utils/llms-utils');
20
22
  * Must run after convert-to-markdown extension to access page.markdownContents.
21
23
  */
22
24
  module.exports.register = function () {
25
+ raiseListenerLimit(this)
23
26
  const logger = this.getLogger('convert-llms-to-txt-extension');
24
27
  let siteUrl = '';
25
28
 
@@ -1,3 +1,4 @@
1
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
1
2
  const path = require('path')
2
3
  const os = require('os')
3
4
  const yaml = require('js-yaml')
@@ -187,6 +188,7 @@ function generateFrontmatter(page) {
187
188
  }
188
189
 
189
190
  module.exports.register = function () {
191
+ raiseListenerLimit(this)
190
192
  const logger = this.getLogger('convert-to-markdown-extension')
191
193
  let playbook
192
194
 
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  module.exports.register = function ({ config }) {
6
+ raiseListenerLimit(this)
4
7
  const logger = this.getLogger('related-docs-extension');
5
8
 
6
9
  this.on('documentsConverted', async ({ contentCatalog, siteCatalog }) => {
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  module.exports.register = function ({ config }) {
6
+ raiseListenerLimit(this)
4
7
  const logger = this.getLogger('related-labs-extension');
5
8
 
6
9
  this.on('documentsConverted', async ({ contentCatalog, siteCatalog }) => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const fs = require('fs')
4
6
  const path = require('path')
5
7
  const os = require('os')
@@ -121,6 +123,7 @@ const DEFAULTS = {
121
123
  }
122
124
 
123
125
  module.exports.register = function ({ config }) {
126
+ raiseListenerLimit(this)
124
127
  const logger = this.getLogger('generate-fields-only-pages-extension')
125
128
 
126
129
  // Merge config with defaults
@@ -1,7 +1,10 @@
1
1
  'use strict';
2
+
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
2
4
  const _ = require('lodash');
3
5
 
4
6
  module.exports.register = function ({ config }) {
7
+ raiseListenerLimit(this)
5
8
  const logger = this.getLogger('generate-index-data-extension');
6
9
 
7
10
  this.on('documentsConverted', async ({ contentCatalog, siteCatalog }) => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Redpanda Connect Category Aggregation Extension
5
7
  *
@@ -11,6 +13,7 @@
11
13
  * Ensure generate-rp-connect-info is listed BEFORE this extension in your playbook.
12
14
  */
13
15
  module.exports.register = function ({ config }) {
16
+ raiseListenerLimit(this)
14
17
  const logger = this.getLogger('redpanda-connect-category-aggregation-extension')
15
18
 
16
19
  this.on('contentClassified', ({ contentCatalog }) => {
@@ -1,4 +1,6 @@
1
1
  'use strict'
2
+
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
2
4
  const fs = require('fs')
3
5
  const path = require('path')
4
6
  const Papa = require('papaparse')
@@ -11,6 +13,7 @@ const DEFAULTS = {
11
13
  }
12
14
 
13
15
  module.exports.register = function ({ config }) {
16
+ raiseListenerLimit(this)
14
17
  const logger = this.getLogger('redpanda-connect-info-extension')
15
18
  const { getAntoraValue } = require('../cli-utils/antora-utils')
16
19
 
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const { execSync } = require('child_process')
4
6
  const fs = require('fs')
5
7
  const path = require('path')
@@ -33,6 +35,7 @@ const { getGitHubToken } = require('../cli-utils/github-token')
33
35
  */
34
36
 
35
37
  module.exports.register = function ({ config, playbook }) {
38
+ raiseListenerLimit(this)
36
39
  const logger = this.getLogger('git-full-clone-extension')
37
40
 
38
41
  logger.info('✓ git-full-clone extension loaded')
@@ -1,8 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const GetLatestConnectTag = require('./version-fetcher/get-latest-connect');
4
6
 
5
7
  module.exports.register = function ({ config }) {
8
+ raiseListenerLimit(this)
6
9
  const logger = this.getLogger('modify-connect-tag-playbook-extension');
7
10
 
8
11
  this.on('contextStarted', async ({ playbook }) => {
@@ -17,7 +17,10 @@
17
17
 
18
18
  'use strict';
19
19
 
20
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
21
+
20
22
  module.exports.register = function ({ config }) {
23
+ raiseListenerLimit(this)
21
24
  const logger = this.getLogger('context-switcher-extension');
22
25
 
23
26
  this.on('documentsConverted', async ({ contentCatalog }) => {
@@ -1,11 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const File = require('vinyl')
4
6
  const { posix: path } = require('path')
5
7
 
6
8
  const ENCODED_SPACE_RX = /%20/g
7
9
 
8
10
  module.exports.register = function () {
11
+ raiseListenerLimit(this)
9
12
  this.once('contextStarted', () => {
10
13
  const { publishFiles: produceRedirectsDelegate } = this.getFunctions()
11
14
  this.replaceFunctions({
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const semver = require('semver');
4
6
  const micromatch = require('micromatch');
5
7
  const formatVersion = require('./util/format-version.js');
@@ -24,6 +26,7 @@ const sanitize = require('./util/sanitize-attributes.js');
24
26
  * - ...
25
27
  */
26
28
  module.exports.register = function ({ config }) {
29
+ raiseListenerLimit(this)
27
30
  const logger = this.getLogger('replace-attributes-extension');
28
31
  const replacements = config.data?.replacements || [];
29
32
 
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  /**
4
6
  * Resolves AsciiDoc xrefs in JSON attachment files to HTML links.
5
7
  *
@@ -19,6 +21,7 @@
19
21
  */
20
22
 
21
23
  module.exports.register = function () {
24
+ raiseListenerLimit(this)
22
25
  const logger = this.getLogger('resolve-xrefs-in-attachments')
23
26
 
24
27
  this.on('contentClassified', ({ contentCatalog }) => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const semver = require('semver')
4
6
 
5
7
  /**
@@ -34,6 +36,7 @@ const PROPERTIES_JSON_RX = /^redpanda-properties-(v\d+\.\d+\.\d+(?:-[\w.]+)?)\.j
34
36
  const CONNECT_JSON_RX = /^connect-(\d+\.\d+\.\d+(?:-[\w.]+)?)\.json$/
35
37
 
36
38
  module.exports.register = function () {
39
+ raiseListenerLimit(this)
37
40
  const logger = this.getLogger('set-available-attachment-versions-extension')
38
41
 
39
42
  this.once('contentClassified', ({ contentCatalog }) => {
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  const yaml = require('js-yaml')
4
6
 
5
7
  /**
@@ -35,6 +37,7 @@ const yaml = require('js-yaml')
35
37
  */
36
38
 
37
39
  module.exports.register = function () {
40
+ raiseListenerLimit(this)
38
41
  const logger = this.getLogger('unified-navigation-extension')
39
42
 
40
43
  this.on('navigationBuilt', ({ contentCatalog }) => {
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  module.exports.register = function ({ config }) {
6
+ raiseListenerLimit(this)
4
7
  const { addToNavigation, unlistedPagesHeading = 'Unlisted Pages' } = config;
5
8
  const logger = this.getLogger('unlisted-pages-extension');
6
9
 
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
+
3
5
  module.exports.register = function () {
6
+ raiseListenerLimit(this)
4
7
  const logger = this.getLogger('unpublish-pages-extension');
5
8
 
6
9
  this.on('documentsConverted', ({ siteCatalog, contentCatalog }) => {
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+
3
+ // Antora's GeneratorContext is one shared EventEmitter for every registered
4
+ // extension, and Redpanda playbooks register enough of them that per-event
5
+ // listener counts legitimately exceed Node's default threshold of 10. Node
6
+ // then prints MaxListenersExceededWarning ("Possible EventEmitter memory
7
+ // leak detected. 11 contentClassified listeners added to [GeneratorContext]")
8
+ // in build logs even though each extension adds exactly one listener per
9
+ // event per build.
10
+ //
11
+ // Every extension in this package raises the limit before subscribing, so
12
+ // the suppression holds no matter which subset of extensions a playbook
13
+ // registers or in what order. The cap is bounded rather than unlimited (0)
14
+ // to keep Node's leak detection meaningful for pathological cases.
15
+ const GENERATOR_CONTEXT_MAX_LISTENERS = 64
16
+
17
+ function raiseListenerLimit (context) {
18
+ if (
19
+ typeof context.getMaxListeners === 'function' &&
20
+ typeof context.setMaxListeners === 'function' &&
21
+ context.getMaxListeners() !== 0 &&
22
+ context.getMaxListeners() < GENERATOR_CONTEXT_MAX_LISTENERS
23
+ ) {
24
+ context.setMaxListeners(GENERATOR_CONTEXT_MAX_LISTENERS)
25
+ }
26
+ }
27
+
28
+ module.exports = { raiseListenerLimit, GENERATOR_CONTEXT_MAX_LISTENERS }
@@ -6,7 +6,10 @@
6
6
 
7
7
  'use strict';
8
8
 
9
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
10
+
9
11
  module.exports.register = function ({ config }) {
12
+ raiseListenerLimit(this)
10
13
  const logger = this.getLogger('attribute-validation-extension');
11
14
 
12
15
  this.on('documentsConverted', async ({ contentCatalog, siteCatalog }) => {
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const { raiseListenerLimit } = require('../util/raise-listener-limit')
4
+
3
5
  module.exports.register = function ({ config }) {
6
+ raiseListenerLimit(this)
4
7
  const GetLatestRedpandaVersion = require('./get-latest-redpanda-version');
5
8
  const GetLatestConsoleVersion = require('./get-latest-console-version');
6
9
  const GetLatestDockerTag = require('./fetch-latest-docker-tag');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -55,7 +55,10 @@ function mergeOverrides(target, overrides) {
55
55
  throw new Error('Target must be a valid object');
56
56
  }
57
57
 
58
- const scalarKeys = ['description', 'type', 'annotated_field', 'version'];
58
+ // 'summary' earns its place here the hard way: overrides.json has carried
59
+ // summary overrides (zmq4, ffi) that were silently dropped because the key
60
+ // fell through every branch below. Summaries feed page meta descriptions.
61
+ const scalarKeys = ['description', 'summary', 'type', 'annotated_field', 'version'];
59
62
 
60
63
  for (const key in overrides) {
61
64
  // === Handle annotated_options ===
@@ -674,17 +677,120 @@ async function generateRpcnConnectorDocs(options) {
674
677
  }
675
678
  }
676
679
 
680
+ // Self-heal published page headers: pages are one-time drafts, so pages
681
+ // created before the template emitted :description: never got one.
682
+ let descriptionBackfill = { backfilled: [], skippedNoSummary: [] };
683
+ if (!writeFullDrafts) {
684
+ descriptionBackfill = backfillPageDescriptions(dataObj);
685
+ }
686
+
677
687
  return {
678
688
  partialsWritten,
679
689
  draftsWritten,
680
690
  partialFiles,
681
691
  draftFiles,
682
- lostSectionWarnings
692
+ lostSectionWarnings,
693
+ descriptionBackfill
683
694
  };
684
695
  }
685
696
 
697
+
698
+ // Connector page families that have per-component pages under
699
+ // modules/components/pages/. Other data keys (config, bloblang-functions,
700
+ // bloblang-methods) have no per-component page to backfill.
701
+ const PAGE_TYPE_DIRS = new Set([
702
+ 'inputs', 'outputs', 'processors', 'caches', 'buffers', 'metrics',
703
+ 'tracers', 'scanners', 'rate-limits', 'rate_limits'
704
+ ]);
705
+
706
+ /**
707
+ * Insert a tagged :description: into existing connector page headers that
708
+ * lack one, sourced from the component's summary in the connector data.
709
+ *
710
+ * Connector pages are one-time first drafts: pages drafted before the
711
+ * template emitted :description: never got one, and nothing rewrote them,
712
+ * so hundreds of published pages ship the generic site meta description
713
+ * even though ~97% of components carry a summary in the source data. This
714
+ * runs on every generation, so the page set self-heals and stays healed.
715
+ *
716
+ * The inserted block matches the connector template's shape: the meta tag
717
+ * region lets single-source stubs inherit the description as a header
718
+ * attribute (see the template comment in connector.hbs).
719
+ *
720
+ * @param {Object} connectorData - Parsed connector data (typed arrays)
721
+ * @param {Object} [options]
722
+ * @param {string} [options.pagesRoot] - Pages directory (default: modules/components/pages under cwd)
723
+ * @param {boolean} [options.dryRun] - Report without writing
724
+ * @returns {{backfilled: string[], skippedNoSummary: string[]}}
725
+ */
726
+ function backfillPageDescriptions (connectorData, { pagesRoot, dryRun = false } = {}) {
727
+ const root = pagesRoot || path.resolve(process.cwd(), 'modules/components/pages');
728
+ const results = { backfilled: [], skippedNoSummary: [] };
729
+ if (!fs.existsSync(root)) return results;
730
+
731
+ for (const [dataKey, items] of Object.entries(connectorData)) {
732
+ if (!PAGE_TYPE_DIRS.has(dataKey) || !Array.isArray(items)) continue;
733
+ // The data uses 'rate-limits' while the pages directory is
734
+ // 'rate_limits'. Resolve whichever spelling exists on disk.
735
+ const typeDir = fs.existsSync(path.join(root, dataKey))
736
+ ? dataKey
737
+ : dataKey.replace(/-/g, '_');
738
+ for (const item of items) {
739
+ if (!item || !item.name) continue;
740
+ const pagePath = path.join(root, typeDir, `${item.name}.adoc`);
741
+ if (!fs.existsSync(pagePath)) continue;
742
+
743
+ const content = fs.readFileSync(pagePath, 'utf8');
744
+ const lines = content.split('\n');
745
+ const headerEnd = lines.findIndex((l) => l.trim() === '');
746
+ const header = lines.slice(0, headerEnd === -1 ? lines.length : headerEnd);
747
+ if (header.some((l) => l.startsWith(':description:'))) continue;
748
+
749
+ // Meta descriptions are read as plain text by search results, link
750
+ // previews, and assistants, so flatten AsciiDoc markup mechanically:
751
+ // xrefs and links become their labels, inline code loses its
752
+ // backticks. No prose is edited.
753
+ const summary = (item.summary || '')
754
+ .replace(/(?:xref|link):[^\[\]]+\[([^\]]*)\]/g, '$1')
755
+ // Bare URL macros (no xref:/link: prefix) and internal xref shorthand
756
+ // also appear in source summaries and render literally if left in place.
757
+ .replace(/https?:\/\/[^\s\[\]]+\[([^\]]*)\]/g, '$1')
758
+ .replace(/<<[^,>]+,([^>]+)>>/g, '$1')
759
+ .replace(/<<([^>]+)>>/g, '$1')
760
+ // Strip the "open in new tab" caret that AsciiDoc URL macros carry
761
+ // inside the link text, e.g. [Open Telemetry collector^].
762
+ .replace(/\^/g, '')
763
+ .replace(/`([^`]*)`/g, '$1')
764
+ .replace(/\s+/g, ' ')
765
+ .trim();
766
+ if (!summary) {
767
+ results.skippedNoSummary.push(`${typeDir}/${item.name}`);
768
+ continue;
769
+ }
770
+
771
+ const insertAt = headerEnd === -1 ? lines.length : headerEnd;
772
+ lines.splice(insertAt, 0, '// tag::meta[]', `:description: ${summary}`, '// end::meta[]');
773
+ if (!dryRun) fs.writeFileSync(pagePath, lines.join('\n'), 'utf8');
774
+ results.backfilled.push(`${typeDir}/${item.name}`);
775
+ }
776
+ }
777
+
778
+ if (results.backfilled.length) {
779
+ console.log(`Backfilled :description: into ${results.backfilled.length} connector page header(s) from source summaries`);
780
+ }
781
+ if (results.skippedNoSummary.length) {
782
+ console.warn(
783
+ `\u26a0 ${results.skippedNoSummary.length} connector page(s) lack both a header description and a source summary: ` +
784
+ results.skippedNoSummary.join(', ') +
785
+ '. Author these in the overrides file or upstream.'
786
+ );
787
+ }
788
+ return results;
789
+ }
790
+
686
791
  module.exports = {
687
792
  generateRpcnConnectorDocs,
793
+ backfillPageDescriptions,
688
794
  mergeOverrides,
689
795
  resolveReferences
690
796
  };
@@ -1,6 +1,8 @@
1
1
  = Bloblang Functions
2
2
  // tag::single-source[]
3
+ // tag::meta[]
3
4
  :description: A list of Bloblang functions
5
+ // end::meta[]
4
6
 
5
7
  // © 2024 Redpanda Data Inc.
6
8
 
@@ -1,6 +1,8 @@
1
1
  = Bloblang Methods
2
2
  // tag::single-source[]
3
+ // tag::meta[]
3
4
  :description: A list of Bloblang methods
5
+ // end::meta[]
4
6
 
5
7
  // © 2024 Redpanda Data Inc.
6
8
 
@@ -3,7 +3,19 @@
3
3
  :type: {{type}}
4
4
  {{#if support}}:support: {{support}}{{/if}}
5
5
  :categories: [{{join categories ", "}}]
6
- :description: {{#if summary}}{{{summary}}}{{else if description}}{{description}}{{else}}// TODO: Missing description. Add a description in the overrides file or source data.{{/if}}
6
+ {{#if summary}}
7
+ // tag::meta[]
8
+ :description: {{{summary}}}
9
+ // end::meta[]
10
+ {{else if description}}
11
+ // tag::meta[]
12
+ :description: {{description}}
13
+ // end::meta[]
14
+ {{/if}}
15
+ {{!-- No fallback line when neither exists: an emitted TODO would become
16
+ the attribute VALUE and stubs would inherit it into their meta tags.
17
+ Missing descriptions are the tracked editorial backlog
18
+ (cloud-docs .github/description-allowlist.txt). --}}
7
19
 
8
20
  component_type_dropdown::[]
9
21
 
@@ -23,7 +23,7 @@ const { spawnSync } = require('child_process')
23
23
  * authoritative: dashified filenames cannot be reversed unambiguously
24
24
  * (rpk-ai-llm-provider could be `llm provider` or `llm-provider`).
25
25
  * @param {string} partialsDir - Directory of generated .adoc partials
26
- * @returns {Array<{file: string, title: string, description: string|undefined}>} Sorted by command path
26
+ * @returns {Array<{file: string, title: string, description: string|undefined, hasMetaTag: boolean}>} Sorted by command path
27
27
  */
28
28
  function readPartialTitles(partialsDir) {
29
29
  const partials = []
@@ -35,11 +35,18 @@ function readPartialTitles(partialsDir) {
35
35
  console.warn(`Warning: no title line in ${file}; skipping`)
36
36
  continue
37
37
  }
38
- // The partial repeats :description: inside its single-source tag, but
39
- // Antora resolves page metadata with a header-only parse that never sees
40
- // the include — the stub must carry the description in its own header.
38
+ // Antora resolves page metadata with a header-only parse that never
39
+ // sees the body include, so the stub must carry the description in its
40
+ // own header. Partials generated with doc-tools >= 5.4 carry a nested
41
+ // meta tag region around :description: that stubs include dynamically;
42
+ // older partials fall back to a literal copy of the header description.
41
43
  const description = (content.match(/^:description:[ \t]*(.+)$/m) || [])[1]
42
- partials.push({ file, title: match[1].trim(), description: description && description.trim() })
44
+ partials.push({
45
+ file,
46
+ title: match[1].trim(),
47
+ description: description && description.trim(),
48
+ hasMetaTag: content.includes('tag::meta['),
49
+ })
43
50
  }
44
51
  // Hierarchical order: sort by command words so parents precede children
45
52
  partials.sort((a, b) => {
@@ -121,11 +128,23 @@ function inferIncludePrefix(stubDir, plugin) {
121
128
  * @param {string} [params.description] - Meta description from the partial header
122
129
  * @param {string} params.includePrefix - Antora resource prefix
123
130
  * @param {Array<string>} params.attributes - Page attribute lines
131
+ * @param {boolean} [params.hasMetaTag] - Partial carries a tag::meta[] region (doc-tools >= 5.4)
124
132
  * @returns {string}
125
133
  */
126
- function renderStub({ title, file, description, includePrefix, attributes }) {
134
+ function renderStub({ title, file, description, includePrefix, attributes, hasMetaTag }) {
127
135
  const lines = [`= ${title}`]
128
- if (description) lines.push(`:description: ${description}`)
136
+ // The description mechanism is always the first header line after the
137
+ // title: a header include for partials with a meta tag region, or a
138
+ // literal copy as the fallback for partials generated before 5.4. Antora
139
+ // resolves page metadata with a header-only parse that stops at the first
140
+ // blank line, so either form must sit above it — an include below the
141
+ // blank line is never evaluated for attributes and the page ships the
142
+ // generic site description.
143
+ if (hasMetaTag) {
144
+ lines.push(`include::${includePrefix}${file}[tag=meta]`)
145
+ } else if (description) {
146
+ lines.push(`:description: ${description}`)
147
+ }
129
148
  for (const attr of attributes) lines.push(attr)
130
149
  lines.push('')
131
150
  lines.push(`include::${includePrefix}${file}[tag=single-source]`)
@@ -162,6 +181,7 @@ function reconcileStubs({
162
181
  const existingStubs = fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc'))
163
182
 
164
183
  const created = []
184
+ const upgraded_ = []
165
185
  const deleted = []
166
186
  const keptNonStub = []
167
187
  const skippedAliasTargets = []
@@ -206,7 +226,34 @@ function reconcileStubs({
206
226
  fs.existsSync(stubDir) ? fs.readdirSync(stubDir).filter(f => f.endsWith('.adoc')) : []
207
227
  )
208
228
  for (const partial of partials) {
209
- if (remainingStubs.has(partial.file)) continue
229
+ if (remainingStubs.has(partial.file)) {
230
+ // Existing stub: upgrade it in place when the partial has gained the
231
+ // meta tag region and the stub does not yet include it. The header
232
+ // include is what lets the stub inherit :description: as page
233
+ // metadata, so upgrading here makes the consumer repo self-heal on
234
+ // the next sync instead of needing a hand-written migration PR.
235
+ if (partial.hasMetaTag && !dryRun) {
236
+ const stubPath = path.join(stubDir, partial.file)
237
+ const stubContent = fs.readFileSync(stubPath, 'utf8')
238
+ if (!stubContent.includes('[tag=meta]')) {
239
+ const metaInclude = `include::${includePrefix}${partial.file}[tag=meta]`
240
+ // A later attribute entry overrides an earlier one (verified
241
+ // empirically against an Antora build), so a literal
242
+ // :description: below the include would win and leave the
243
+ // include dead weight. These are generated reference stubs, so
244
+ // any literal is a static backfill the include supersedes:
245
+ // strip it. Hand-maintained prose stubs never pass through this
246
+ // reconciler, so intentionally curated descriptions are safe.
247
+ let upgraded = stubContent.replace(/^:description: .*\n/m, '')
248
+ upgraded = upgraded.replace(/^(= .+)$/m, `$1\n${metaInclude}`)
249
+ if (upgraded !== stubContent) {
250
+ fs.writeFileSync(stubPath, upgraded, 'utf8')
251
+ upgraded_.push(partial.file)
252
+ }
253
+ }
254
+ }
255
+ continue
256
+ }
210
257
  if (aliasClaims.has(partial.file)) {
211
258
  skippedAliasTargets.push({ file: partial.file, claimedBy: aliasClaims.get(partial.file) })
212
259
  continue
@@ -285,7 +332,7 @@ function reconcileStubs({
285
332
  }
286
333
  }
287
334
 
288
- return { created, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
335
+ return { created, upgraded: upgraded_, deleted, keptNonStub, navUpdated, renameCandidates, skippedAliasTargets }
289
336
  }
290
337
 
291
338
  module.exports = {
@@ -6,7 +6,12 @@
6
6
  page at worst. Availability pages are excluded from cloud by not
7
7
  generating a stub for them in cloud-docs. --}}
8
8
  = {{commandPath}}
9
+ {{!-- Comment lines are legal inside an AsciiDoc header, so the meta tag
10
+ region can wrap the page's single description. Stub pages inherit it
11
+ with a header include (see the note at the single-source tag). --}}
12
+ // tag::meta[]
9
13
  :description: {{{wrapDescriptionPassthrough shortDesc}}}
14
+ // end::meta[]
10
15
  {{#if pageAliases}}
11
16
  :page-aliases: {{{pageAliases}}}
12
17
  {{/if}}
@@ -23,11 +28,24 @@
23
28
  {{/each}}
24
29
 
25
30
  // tag::single-source[]
26
- :description: {{{wrapDescriptionPassthrough shortDesc}}}
27
- {{!-- The description attribute is repeated inside the tagged region so
28
- single-sourcing consumers (stub pages that include this tag) inherit
29
- the meta description. page-aliases and page-platforms deliberately stay
30
- outside the tag: consumers must not inherit alias registrations. --}}
31
+ {{!-- Stub pages inherit this page's description by including the header's
32
+ meta tag region as a HEADER attribute. A stub that only includes
33
+ tag=single-source after a blank line never gets it: Antora resolves
34
+ page metadata with a header-only parse that stops at the stub's first
35
+ blank line, so the include is never evaluated for attributes and the
36
+ page ships the generic site description (verified empirically,
37
+ 2026-08-05, including that a headerless include destroys the body).
38
+ The working stub shape is two includes:
39
+
40
+ = Page title
41
+ include::...page.adoc[tag=meta] <- header: no blank line above
42
+ <- blank line ends the header
43
+ include::...page.adoc[tag=single-source]
44
+
45
+ Earlier revisions repeated :description: inside this region on the
46
+ wrong belief that it reached stub metadata by itself; it never did.
47
+ page-aliases and page-platforms deliberately stay outside both tag
48
+ regions: consumers must not inherit alias registrations. --}}
31
49
  {{!-- Deprecation notice --}}
32
50
  {{#if deprecated}}
33
51
  [CAUTION]