@redpanda-data/docs-extensions-and-macros 5.35.1 → 5.37.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.
@@ -0,0 +1,180 @@
1
+ /* Publishes the Kapa source-group mapping to the UI as an AsciiDoc attribute.
2
+ *
3
+ * Example use in the playbook:
4
+ * antora:
5
+ * extensions:
6
+ * - require: '@redpanda-data/docs-extensions-and-macros/extensions/kapa-source-groups'
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * docs-ui's get-kapa-source-groups helper needs to turn the page's version
11
+ * segment into a Kapa source group id, so Ask AI retrieval is scoped to the docs
12
+ * version the reader is actually on (DOC-1807, DOC-2450). docs-ui does not depend
13
+ * on this package and must not carry a second copy of the mapping, which would
14
+ * drift silently. So the mapping travels as an attribute: generated here by
15
+ * `doc-tools generate kapa-source-groups`, committed to docs-data, and merged
16
+ * onto every component version at build time.
17
+ *
18
+ * Set on every component version rather than only the versioned ones, because the
19
+ * helper needs the mapping on unversioned pages too. Those resolve to the
20
+ * mapping's default_segment, and sending no filter there is exactly what caused
21
+ * DOC-2450: the reporter was on a page with no version of its own.
22
+ *
23
+ * DEGRADATION
24
+ * -----------
25
+ * Every failure sets nothing and warns. The helper treats a missing attribute as
26
+ * "send no source group", which is the pre-DOC-2450 behaviour of searching every
27
+ * version. That is the right way to fail: a wrong group is worse than no group,
28
+ * because scoping to a group that does not hold the reader's version returns only
29
+ * Kapa's global sources, silently and with no error.
30
+ */
31
+
32
+ const fs = require('fs')
33
+ const path = require('path')
34
+ const { raiseListenerLimit } = require('./util/raise-listener-limit')
35
+
36
+ const ATTRIBUTE_NAME = 'kapa-source-groups'
37
+ // Sits beside assets/widgets/, which proxy-api-docs.js already fetches from the
38
+ // site origin, so the /api/ path needs no new hostname or auth.
39
+ const ASSET_DIR = 'assets/data'
40
+ const ASSET_FILENAME = 'kapa-source-groups.json'
41
+ const DEFAULT_MAPPING_PATH = path.join(__dirname, '..', 'docs-data', 'kapa-source-groups.json')
42
+
43
+ /**
44
+ * Validate the shape the helper depends on, so a malformed mapping is caught at
45
+ * build time with a named reason rather than producing pages that quietly lose
46
+ * version scoping.
47
+ *
48
+ * @param {*} mapping
49
+ * @returns {string|null} Reason it is unusable, or null when it is fine
50
+ */
51
+ function validateMapping (mapping) {
52
+ if (!mapping || typeof mapping !== 'object') return 'not an object'
53
+ if (!mapping.segments || typeof mapping.segments !== 'object') return 'no segments object'
54
+ const segments = Object.keys(mapping.segments)
55
+ if (segments.length === 0) return 'segments is empty'
56
+ if (!mapping.default_segment) return 'no default_segment'
57
+ if (!mapping.segments[mapping.default_segment]) {
58
+ // The helper falls back to default_segment for every unversioned page, so a
59
+ // dangling default silently disables scoping across most of the site.
60
+ return `default_segment "${mapping.default_segment}" is not one of the segments (${segments.join(', ')})`
61
+ }
62
+ const missing = segments.filter((s) => !mapping.segments[s] || !mapping.segments[s].group_id)
63
+ if (missing.length) return `segments missing group_id: ${missing.join(', ')}`
64
+ return null
65
+ }
66
+
67
+ module.exports.register = function ({ config = {} } = {}) {
68
+ const logger = this.getLogger('kapa-source-groups-extension')
69
+
70
+ // Three listeners on a generator context that already carries 30+ extensions,
71
+ // which trips Node's default max-listeners warning. Raised before subscribing,
72
+ // per __tests__/extensions/raise-listener-limit.test.js.
73
+ raiseListenerLimit(this)
74
+ const mappingPath = config.mapping_file ? path.resolve(config.mapping_file) : DEFAULT_MAPPING_PATH
75
+
76
+ // Loaded on first use and reused by all three listeners, so a missing or
77
+ // broken mapping warns once per build rather than once per event.
78
+ let loaded = false
79
+ let cached = null
80
+ function load () {
81
+ if (loaded) return cached
82
+ loaded = true
83
+ cached = readMapping()
84
+ return cached
85
+ }
86
+
87
+ /** Read + validate, or return null having warned with the consequence. */
88
+ function readMapping () {
89
+ let mapping
90
+ try {
91
+ if (!fs.existsSync(mappingPath)) {
92
+ logger.warn(
93
+ `Kapa source-group mapping not found at ${mappingPath}. Ask AI will search every docs version. ` +
94
+ 'Generate it with: doc-tools generate kapa-source-groups'
95
+ )
96
+ return null
97
+ }
98
+ mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8'))
99
+ } catch (err) {
100
+ logger.warn(`Could not read the Kapa source-group mapping at ${mappingPath}: ${err.message}. Ask AI will search every docs version.`)
101
+ return null
102
+ }
103
+ const problem = validateMapping(mapping)
104
+ if (problem) {
105
+ logger.warn(`Kapa source-group mapping at ${mappingPath} is unusable (${problem}). Ask AI will search every docs version.`)
106
+ return null
107
+ }
108
+ return mapping
109
+ }
110
+
111
+ // site.keys reaches EVERY page, including ones with no component: the 404 page
112
+ // renders the Ask AI panel but has no page.component or page.componentVersion,
113
+ // so a component-version attribute alone leaves it unscoped. Its layout pulls
114
+ // in head and header, both of which already read site.keys, so this is the one
115
+ // channel that covers it. Set in playbookBuilt because the site UI model is
116
+ // derived from the playbook.
117
+ this.on('playbookBuilt', ({ playbook }) => {
118
+ const mapping = load()
119
+ if (!mapping) return
120
+ playbook.site = playbook.site || {}
121
+ playbook.site.keys = playbook.site.keys || {}
122
+ if (playbook.site.keys[ATTRIBUTE_NAME] === undefined) {
123
+ playbook.site.keys[ATTRIBUTE_NAME] = JSON.stringify(mapping)
124
+ }
125
+ })
126
+
127
+ // The /api/ reference is Bump.sh HTML assembled by docs-site's proxy-api-docs
128
+ // edge function, which has no Antora page context and cannot import from
129
+ // node_modules. Publishing the mapping as a site asset lets that function read
130
+ // the default group at request time, in parallel with the widget fetches it
131
+ // already does. The alternative -- a UUID pasted into docs-ui's static widget
132
+ // context or a Netlify env var -- is a second copy of the mapping that drifts
133
+ // silently the first time a group is recreated.
134
+ this.on('beforePublish', ({ siteCatalog }) => {
135
+ const mapping = load()
136
+ if (!mapping) return
137
+ siteCatalog.addFile({
138
+ contents: Buffer.from(JSON.stringify(mapping, null, 2) + '\n', 'utf8'),
139
+ out: { path: path.join(ASSET_DIR, ASSET_FILENAME) },
140
+ })
141
+ logger.info(`Kapa source groups: published ${ASSET_DIR}/${ASSET_FILENAME} for the /api/ proxy.`)
142
+ })
143
+
144
+ this.on('contentClassified', async ({ contentCatalog }) => {
145
+ const mapping = load()
146
+ if (!mapping) return
147
+
148
+ // Serialised once. The helper accepts a string or an object, but a string is
149
+ // what survives Antora's attribute handling unchanged, and it keeps the
150
+ // attribute value comparable between builds.
151
+ const serialised = JSON.stringify(mapping)
152
+
153
+ let versions = 0
154
+ const components = await contentCatalog.getComponents()
155
+ for (const component of components) {
156
+ component.versions.forEach(({ asciidoc }) => {
157
+ if (!asciidoc) return
158
+ asciidoc.attributes = asciidoc.attributes || {}
159
+ // Do not clobber an explicit override: a playbook or antora.yml that
160
+ // already sets this wins, matching how add-global-attributes.js lets
161
+ // component attributes beat the shared file.
162
+ if (asciidoc.attributes[ATTRIBUTE_NAME] === undefined) {
163
+ asciidoc.attributes[ATTRIBUTE_NAME] = serialised
164
+ }
165
+ versions += 1
166
+ })
167
+ }
168
+
169
+ logger.info(
170
+ `Kapa source groups: set ${ATTRIBUTE_NAME} on ${versions} component version(s); ` +
171
+ `${Object.keys(mapping.segments).length} version segments, default "${mapping.default_segment}".`
172
+ )
173
+ })
174
+ }
175
+
176
+ module.exports.validateMapping = validateMapping
177
+ module.exports.ATTRIBUTE_NAME = ATTRIBUTE_NAME
178
+ module.exports.DEFAULT_MAPPING_PATH = DEFAULT_MAPPING_PATH
179
+ module.exports.ASSET_DIR = ASSET_DIR
180
+ module.exports.ASSET_FILENAME = ASSET_FILENAME
@@ -13,6 +13,8 @@
13
13
  *
14
14
  * A block closes only on a delimiter of the same length as the one that opened
15
15
  * it; a longer run, such as the row of dashes in a rendered table, is content.
16
+ * - indented literal paragraphs: an indented line that opens a block is
17
+ * rendered verbatim, so a URL on it is text and not a link
16
18
  * - line comments (//), which Asciidoctor drops entirely
17
19
  * - inline code spans (`...`)
18
20
  *
@@ -60,6 +62,23 @@ const ATTRIBUTE_REFERENCE_RX = /\{[a-zA-Z0-9_][a-zA-Z0-9_-]*\}/
60
62
  // prose cannot swallow the rest of the document.
61
63
  const MAX_WRAPPED_LABEL_LENGTH = 500
62
64
 
65
+ // An indented line that opens a block is a literal paragraph: Asciidoctor
66
+ // renders it verbatim, so any URL on it is text rather than a link. This is
67
+ // where connector descriptions put their shell examples, which is how the
68
+ // three comma-joined gcloud scope strings in google_drive_list_labels were
69
+ // read as one URL and reported broken.
70
+ const INDENTED_RX = /^[ \t]+\S/
71
+ // Indentation does not make a literal paragraph out of a list item, a table
72
+ // cell or a description list: Asciidoctor still parses those, so their links
73
+ // are real. Verified against @asciidoctor/core, marker by marker (-, *, **,
74
+ // ., .., 1., a., |, term::).
75
+ const INDENTED_PARSED_RX = /^[ \t]+(?:[*.-]+[ \t]|\d+\.[ \t]|[a-zA-Z]\.[ \t]|\||\S.*::(?:[ \t]|$))/
76
+ // Lines a block can start after, so an indented line that follows one opens a
77
+ // literal paragraph: a list continuation (+), a block attribute list
78
+ // ([source,yaml]) or a block title (.Title). A blank line and the close of a
79
+ // delimited block do the same and are handled in the scan loop.
80
+ const BLOCK_START_RX = /^(?:\+|\[.*\]|\.\S.*)$/
81
+
63
82
  // A delimiter run, plus the info string a fence may carry. Comment blocks
64
83
  // (////) come first in the alternation so a //// line is not mistaken for the
65
84
  // start of a line comment.
@@ -108,22 +127,48 @@ function closesBlock (delimiter, open) {
108
127
  return delimiter.char === open.char && delimiter.length === open.length && !delimiter.info
109
128
  }
110
129
 
130
+ /**
131
+ * True when an indented line at the start of a block opens a literal
132
+ * paragraph, rather than continuing a construct Asciidoctor still parses.
133
+ */
134
+ function opensLiteralParagraph (line) {
135
+ return INDENTED_RX.test(line) && !INDENTED_PARSED_RX.test(line)
136
+ }
137
+
111
138
  function scanContentUrls (content) {
112
139
  const matches = []
113
140
  const lines = content.split('\n')
114
141
  let offset = 0
115
142
  let openDelimiter = null
143
+ // A literal paragraph runs from the indented line that opened it to the next
144
+ // blank line, and takes any unindented lines in between with it.
145
+ let inLiteralParagraph = false
146
+ // A block can start at the top of the file, and again after every line that
147
+ // ended the last one.
148
+ let atBlockStart = true
116
149
  for (const rawLine of lines) {
117
150
  const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
118
151
  const delimiter = blockDelimiter(line)
119
152
  if (openDelimiter) {
120
153
  // Inside a block, only its own matching delimiter gets out. Anything
121
154
  // else on this line is content and is never scanned.
122
- if (delimiter && closesBlock(delimiter, openDelimiter)) openDelimiter = null
155
+ if (delimiter && closesBlock(delimiter, openDelimiter)) {
156
+ openDelimiter = null
157
+ atBlockStart = true
158
+ }
123
159
  } else if (delimiter) {
124
160
  openDelimiter = delimiter
125
- } else if (!LINE_COMMENT_RX.test(line)) {
126
- scanLine(line, offset, matches, content)
161
+ inLiteralParagraph = false
162
+ } else {
163
+ const blank = !line.trim()
164
+ if (inLiteralParagraph) {
165
+ if (blank) inLiteralParagraph = false
166
+ } else if (atBlockStart && opensLiteralParagraph(line)) {
167
+ inLiteralParagraph = true
168
+ } else if (!LINE_COMMENT_RX.test(line)) {
169
+ scanLine(line, offset, matches, content)
170
+ }
171
+ atBlockStart = blank || BLOCK_START_RX.test(line)
127
172
  }
128
173
  offset += rawLine.length + 1
129
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.35.1",
3
+ "version": "5.37.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -92,7 +92,9 @@
92
92
  "./macros/enterprise": "./macros/enterprise.js",
93
93
  "./macros/prop": "./macros/prop.js",
94
94
  "./extensions/property-page-index": "./extensions/property-page-index.js",
95
- "./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js"
95
+ "./extensions/render-property-descriptions": "./extensions/render-property-descriptions.js",
96
+ "./extensions/kapa-source-groups": "./extensions/kapa-source-groups.js",
97
+ "./docs-data/kapa-source-groups.json": "./docs-data/kapa-source-groups.json"
96
98
  },
97
99
  "files": [
98
100
  "extensions",
@@ -104,7 +106,9 @@
104
106
  "tools",
105
107
  "docker-compose",
106
108
  "mcp",
107
- "docs-data/rpk-overrides.schema.json"
109
+ "docs-data/rpk-overrides.schema.json",
110
+ "docs-data/kapa-source-groups.json",
111
+ "docs-data/kapa-source-groups.schema.json"
108
112
  ],
109
113
  "license": "ISC",
110
114
  "engines": {
@@ -138,11 +138,19 @@ function parseJSDocComments(sourceFile) {
138
138
  // was documented with the first command's prose. That is exactly what
139
139
  // happened to `generate migrate-rpcn-descriptions`, which shipped a
140
140
  // regenerated section describing migrate-rpcn-metadata.
141
- const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(?:programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
141
+ //
142
+ // The group is captured too, because a command NAME is not unique: the same
143
+ // name can be registered under both `automation` (generate) and `validation`
144
+ // (validate). `kapa-source-groups` is, and keying on the bare name alone made
145
+ // the later registration silently overwrite the earlier, so the
146
+ // `generate kapa-source-groups` section shipped describing the validate
147
+ // command, complete with validate examples under a generate usage line.
148
+ // Same failure as the migrate-rpcn one above, one level up.
149
+ const pattern = /\/\*\*\s*((?:(?!\*\/)[\s\S])*?)\s*\*\/\s*(programCli|automation|validation)\s*\.command\(['"]([^'"]+)['"]\)/g;
142
150
 
143
151
  let match;
144
152
  while ((match = pattern.exec(content)) !== null) {
145
- const [, commentText, commandName] = match;
153
+ const [, commentText, group, commandName] = match;
146
154
 
147
155
  // Parse the comment into sections
148
156
  const parsed = {
@@ -189,7 +197,12 @@ function parseJSDocComments(sourceFile) {
189
197
  .trim();
190
198
  }
191
199
 
192
- comments[commandName] = parsed;
200
+ // Keyed by the CLI path a reader would type, so `generate x` and
201
+ // `validate x` cannot collide. The bare name is kept as well, for the
202
+ // top-level commands registered straight onto programCli.
203
+ const groupPrefix = { automation: 'generate ', validation: 'validate ', programCli: '' }[group]
204
+ comments[`${groupPrefix}${commandName}`] = parsed;
205
+ if (comments[commandName] === undefined) comments[commandName] = parsed;
193
206
  }
194
207
 
195
208
  return comments;
@@ -290,7 +303,9 @@ IMPORTANT: This documentation is auto-generated. Do not edit manually. Run \`npm
290
303
  const label = commandPath.join(' ');
291
304
  console.log(`${' '.repeat(level - 1)}Generating docs for: ${label}`);
292
305
  const data = parseHelp(getHelpOutput(label));
293
- const jsdoc = jsdocs[commandPath[commandPath.length - 1]];
306
+ // Prefer the full command path, falling back to the bare name so that
307
+ // commands registered directly on programCli still resolve.
308
+ const jsdoc = jsdocs[label] || jsdocs[commandPath[commandPath.length - 1]];
294
309
  doc += generateCommandDoc(label, data, jsdoc, level);
295
310
  for (const child of data.commands) {
296
311
  // Commander lists `help [command]` in every group; it is not a command
@@ -0,0 +1,33 @@
1
+ /**
2
+ * fetch() with a deadline that covers the WHOLE exchange, body included.
3
+ *
4
+ * The obvious shape, `try { res = await fetch(url, { signal }) } finally
5
+ * { clearTimeout(timer) }` and then `await res.text()`, only bounds the time to
6
+ * headers. fetch resolves as soon as headers arrive, the timer is cleared, and a
7
+ * server that then stalls mid-body holds the caller forever. For a scheduled
8
+ * check that is a job that never finishes rather than one that exits 2, so
9
+ * nobody is told and the next run queues behind it.
10
+ *
11
+ * @param {typeof fetch} fetchImpl
12
+ * @param {string} url
13
+ * @param {RequestInit} init - Merged with the abort signal
14
+ * @param {number} timeoutMs
15
+ * @param {(res: Response, signal: AbortSignal) => Promise<*>} read
16
+ * Consumes the body while the timer is still armed. Return null for a
17
+ * response you do not want to read (a non-OK status, say) so the caller can
18
+ * inspect res.status first.
19
+ * @returns {Promise<{res: Response, body: *}>}
20
+ */
21
+ async function fetchWithDeadline (fetchImpl, url, init, timeoutMs, read) {
22
+ const controller = new AbortController()
23
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
24
+ try {
25
+ const res = await fetchImpl(url, { ...init, signal: controller.signal })
26
+ const body = await read(res, controller.signal)
27
+ return { res, body }
28
+ } finally {
29
+ clearTimeout(timer)
30
+ }
31
+ }
32
+
33
+ module.exports = { fetchWithDeadline }