@redpanda-data/docs-extensions-and-macros 5.10.0 → 5.11.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.
- package/bin/doc-tools.js +105 -43
- package/cli-utils/enterprise-sources.js +244 -0
- package/cli-utils/helm-commented-values.js +361 -0
- package/cli-utils/self-managed-docs-branch.js +32 -24
- package/cli-utils/setup-mcp.js +4 -3
- package/mcp/USER_GUIDE.adoc +1 -1
- package/package.json +2 -1
- package/tools/enterprise-features/parsers.js +0 -14
- package/tools/enterprise-features/verify.js +28 -14
- package/tools/generate-cli-docs.js +1 -0
- package/tools/rpk-docs/generate-rpk-docs.js +62 -0
- package/tools/rpk-docs/generate-x-env-partial.js +284 -0
- package/tools/rpk-docs/rpk-docs-handler.js +11 -21
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts documentation for commented-out values in a Helm chart's
|
|
5
|
+
* values.yaml, and injects the resulting entries into the AsciiDoc reference
|
|
6
|
+
* that `doc-tools generate helm-spec` produces.
|
|
7
|
+
*
|
|
8
|
+
* helm-docs only renders rows for keys that exist in the YAML tree, so
|
|
9
|
+
* optional values that ship commented out (for example `external.domain` in
|
|
10
|
+
* the Redpanda chart) never appear in the generated reference, even when the
|
|
11
|
+
* chart author wrote a `# --` description for them. This module closes that
|
|
12
|
+
* gap without requiring any change to chart behavior.
|
|
13
|
+
*
|
|
14
|
+
* Two comment conventions are recognized:
|
|
15
|
+
*
|
|
16
|
+
* 1. helm-docs style, attached to a commented-out key. A comment block that
|
|
17
|
+
* contains a `# -- description` marker and is immediately followed by a
|
|
18
|
+
* commented-out key documents that key. The key's path is derived from
|
|
19
|
+
* the enclosing uncommented keys by indentation:
|
|
20
|
+
*
|
|
21
|
+
* external:
|
|
22
|
+
* # -- Optional domain advertised to external clients.
|
|
23
|
+
* # domain: local
|
|
24
|
+
*
|
|
25
|
+
* yields `external.domain`.
|
|
26
|
+
*
|
|
27
|
+
* 2. Explicit, fully-qualified syntax that works anywhere in the file:
|
|
28
|
+
*
|
|
29
|
+
* # @doc external.addresses -- Optional list of advertised addresses.
|
|
30
|
+
* # @default -- `nil`
|
|
31
|
+
*
|
|
32
|
+
* In both forms, an optional `# @default -- text` line overrides the
|
|
33
|
+
* displayed default, which is otherwise `nil`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// Key names never contain dots or slashes: a dotted or slashed "key" in a
|
|
37
|
+
// comment is prose or a URL (`# docker.redpanda.com/...:4.101.0`), and
|
|
38
|
+
// treating it as a key both fabricates a path and truncates the description.
|
|
39
|
+
// Fully-qualified dotted paths are supported through the @doc syntax instead.
|
|
40
|
+
const COMMENTED_KEY_RE = /^(\s*)#(\s{0,4})([a-z][A-Za-z0-9_-]*):\s*(.*)$/
|
|
41
|
+
const REAL_KEY_RE = /^(\s*)([A-Za-z0-9_."/-]+):(.*)$/
|
|
42
|
+
// `(?!-)` keeps comment dividers such as `# ----------------` from being
|
|
43
|
+
// mistaken for an empty description marker.
|
|
44
|
+
const DESC_MARKER_RE = /^\s*#\s*--\s*(?!-)(.*)$/
|
|
45
|
+
const AT_DOC_RE = /^\s*#\s*@doc\s+([A-Za-z0-9_./-]+)\s+--\s*(.*)$/
|
|
46
|
+
const AT_DEFAULT_RE = /^\s*#\s*@default\s+--\s*(.*)$/
|
|
47
|
+
const COMMENT_RE = /^\s*#\s?(.*)$/
|
|
48
|
+
// A block scalar header may carry an explicit indentation indicator digit on
|
|
49
|
+
// either side of the chomping indicator: `|2`, `|-2`, `|2-`, `>2`, ...
|
|
50
|
+
const BLOCK_SCALAR_RE = /[|>][0-9]?[+-]?[0-9]?\s*(#.*)?$/
|
|
51
|
+
const LIST_ITEM_RE = /^\s*-\s/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse values.yaml text and return entries for documented commented-out keys.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} yamlText - Raw contents of values.yaml.
|
|
57
|
+
* @returns {Array<{path: string, description: string, default: string}>}
|
|
58
|
+
*/
|
|
59
|
+
function extractCommentedValueDocs (yamlText) {
|
|
60
|
+
const lines = yamlText.split(/\r?\n/)
|
|
61
|
+
const entries = []
|
|
62
|
+
const stack = [] // enclosing real keys: { name, indent }
|
|
63
|
+
|
|
64
|
+
// Accumulating comment block: { descLines, default, candidate }. The
|
|
65
|
+
// candidate is the key the block documents; emission is deferred to the
|
|
66
|
+
// end of the block so that a later key line at the same depth supersedes a
|
|
67
|
+
// prose line that merely looks like a key (`# example:`, `# default: 30s`).
|
|
68
|
+
let block = null
|
|
69
|
+
let atDoc = null // accumulating @doc entry: { path, descLines, default }
|
|
70
|
+
let skipScalarIndent = -1 // inside a block scalar when >= 0
|
|
71
|
+
let suppressInnerIndent = -1 // inside an emitted commented key's subtree when >= 0
|
|
72
|
+
|
|
73
|
+
const flushAtDoc = () => {
|
|
74
|
+
if (atDoc) {
|
|
75
|
+
entries.push({
|
|
76
|
+
path: atDoc.path,
|
|
77
|
+
description: atDoc.descLines.join('\n').trim(),
|
|
78
|
+
default: atDoc.default || '`nil`',
|
|
79
|
+
})
|
|
80
|
+
atDoc = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Emit the active block's candidate, if any, and close the block. The
|
|
85
|
+
// emitted key suppresses its own commented subtree until real content
|
|
86
|
+
// appears, so nested example structures do not emit bogus paths.
|
|
87
|
+
const flushBlock = () => {
|
|
88
|
+
if (block && block.candidate) {
|
|
89
|
+
const { name, effIndent } = block.candidate
|
|
90
|
+
const parents = stack.filter((k) => k.indent < effIndent).map((k) => k.name)
|
|
91
|
+
entries.push({
|
|
92
|
+
path: [...parents, name].join('.'),
|
|
93
|
+
description: block.descLines.join('\n').trim(),
|
|
94
|
+
default: block.default || '`nil`',
|
|
95
|
+
})
|
|
96
|
+
suppressInnerIndent = effIndent
|
|
97
|
+
}
|
|
98
|
+
block = null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
// Skip the body of block scalars so their content is never mistaken for
|
|
103
|
+
// keys or comments.
|
|
104
|
+
if (skipScalarIndent >= 0) {
|
|
105
|
+
const indent = line.match(/^\s*/)[0].length
|
|
106
|
+
if (line.trim() === '' || indent > skipScalarIndent) continue
|
|
107
|
+
skipScalarIndent = -1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const atDocMatch = line.match(AT_DOC_RE)
|
|
111
|
+
if (atDocMatch) {
|
|
112
|
+
flushAtDoc()
|
|
113
|
+
flushBlock()
|
|
114
|
+
atDoc = { path: atDocMatch[1], descLines: [atDocMatch[2]], default: '' }
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const atDefault = line.match(AT_DEFAULT_RE)
|
|
119
|
+
if (atDefault) {
|
|
120
|
+
if (atDoc) atDoc.default = atDefault[1].trim()
|
|
121
|
+
else if (block) block.default = atDefault[1].trim()
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const commentedKey = line.match(COMMENTED_KEY_RE)
|
|
126
|
+
if (commentedKey) {
|
|
127
|
+
// A commented-out key line terminates an @doc entry rather than being
|
|
128
|
+
// swallowed into its description, so @doc lines can sit directly above
|
|
129
|
+
// the commented key they document.
|
|
130
|
+
flushAtDoc()
|
|
131
|
+
// Authors indent a commented key either before the marker (" # child:")
|
|
132
|
+
// or after it ("# child:"), and the two forms mean the same nesting.
|
|
133
|
+
// Counting only one side made them disagree: a child indented before the
|
|
134
|
+
// marker escaped its parent's subtree and was emitted as a top-level
|
|
135
|
+
// path. Fold both sides into one depth, treating the single space that
|
|
136
|
+
// separates "#" from its text as punctuation rather than indentation, so
|
|
137
|
+
// the result lines up with the real-key indents held on the stack.
|
|
138
|
+
const effIndent = commentedKey[1].length + Math.max(0, commentedKey[2].length - 1)
|
|
139
|
+
// A previously emitted commented key suppresses its own commented
|
|
140
|
+
// subtree, so nested example structures do not emit bogus paths.
|
|
141
|
+
if (suppressInnerIndent >= 0 && effIndent > suppressInnerIndent) {
|
|
142
|
+
block = null
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
suppressInnerIndent = -1
|
|
146
|
+
// Only bare keys or simple scalar values look like real chart values.
|
|
147
|
+
// Prose such as `# Warning: If you use LoadBalancers...` has spaces in
|
|
148
|
+
// the trailing text and is rejected.
|
|
149
|
+
const value = commentedKey[4].trim()
|
|
150
|
+
// Reject URL-like lines such as `# https://github.com/...`, where the
|
|
151
|
+
// "key" is a URL scheme and the "value" is the scheme-relative part.
|
|
152
|
+
const simpleValue = (value === '' || !/\s/.test(value)) && !value.startsWith('/')
|
|
153
|
+
if (block && simpleValue) {
|
|
154
|
+
if (!block.candidate || effIndent <= block.candidate.effIndent) {
|
|
155
|
+
// The last key-shaped line at the block's shallowest depth wins:
|
|
156
|
+
// an earlier line at the same depth was prose shaped like a key,
|
|
157
|
+
// and deeper lines are nested example structures.
|
|
158
|
+
block.candidate = { name: commentedKey[3], effIndent }
|
|
159
|
+
}
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const descMarker = line.match(DESC_MARKER_RE)
|
|
165
|
+
if (descMarker) {
|
|
166
|
+
flushAtDoc()
|
|
167
|
+
flushBlock()
|
|
168
|
+
block = { descLines: [descMarker[1]], default: '', candidate: null }
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const comment = line.match(COMMENT_RE)
|
|
173
|
+
if (comment && line.trim().startsWith('#')) {
|
|
174
|
+
if (atDoc) atDoc.descLines.push(comment[1])
|
|
175
|
+
// Description lines precede the documented key; once a candidate
|
|
176
|
+
// exists, trailing comment lines belong to its example structure.
|
|
177
|
+
else if (block && !block.candidate) block.descLines.push(comment[1])
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Any non-comment line ends comment accumulation. Only real content also
|
|
182
|
+
// ends subtree suppression: a blank line inside a commented-out example
|
|
183
|
+
// structure must not re-enable emission against a stale parent key, since
|
|
184
|
+
// the real-key stack is not rebuilt by comment lines.
|
|
185
|
+
flushAtDoc()
|
|
186
|
+
flushBlock()
|
|
187
|
+
if (line.trim() === '') continue
|
|
188
|
+
suppressInnerIndent = -1
|
|
189
|
+
|
|
190
|
+
if (LIST_ITEM_RE.test(line)) continue
|
|
191
|
+
|
|
192
|
+
const realKey = line.match(REAL_KEY_RE)
|
|
193
|
+
if (realKey) {
|
|
194
|
+
const indent = realKey[1].length
|
|
195
|
+
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
|
196
|
+
stack.pop()
|
|
197
|
+
}
|
|
198
|
+
stack.push({ name: realKey[2].replace(/^"|"$/g, ''), indent })
|
|
199
|
+
if (BLOCK_SCALAR_RE.test(realKey[3])) skipScalarIndent = indent
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
flushAtDoc()
|
|
204
|
+
flushBlock()
|
|
205
|
+
return entries
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check a dotted value path against a chart's values.schema.json. A path is
|
|
210
|
+
* rejected only when the walk can prove the schema forbids it: an object
|
|
211
|
+
* level whose `properties` lack the segment, whose `patternProperties` (if
|
|
212
|
+
* any) do not match it, and whose `additionalProperties` is `false`.
|
|
213
|
+
* Anything the walk cannot interpret ($ref, allOf, open objects) is allowed,
|
|
214
|
+
* so an incomplete schema never hides documentation.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} schema - Parsed values.schema.json.
|
|
217
|
+
* @param {string} path - Dotted value path, for example `external.domain`.
|
|
218
|
+
* @returns {boolean}
|
|
219
|
+
*/
|
|
220
|
+
function isPathAllowedBySchema (schema, path) {
|
|
221
|
+
let node = schema
|
|
222
|
+
for (const segment of path.split('.')) {
|
|
223
|
+
if (!node || typeof node !== 'object' || node.$ref || node.allOf || node.anyOf || node.oneOf) {
|
|
224
|
+
return true
|
|
225
|
+
}
|
|
226
|
+
const props = node.properties
|
|
227
|
+
if (props && Object.prototype.hasOwnProperty.call(props, segment)) {
|
|
228
|
+
node = props[segment]
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (node.patternProperties) {
|
|
232
|
+
const pattern = Object.keys(node.patternProperties)
|
|
233
|
+
.find((p) => { try { return new RegExp(p).test(segment) } catch { return true } })
|
|
234
|
+
if (pattern !== undefined) {
|
|
235
|
+
node = node.patternProperties[pattern]
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return node.additionalProperties !== false
|
|
240
|
+
}
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Split extracted entries into those permitted by the chart's
|
|
246
|
+
* values.schema.json and those provably rejected by it, such as keys whose
|
|
247
|
+
* `# --` comment is a deprecation notice for a removed value.
|
|
248
|
+
*
|
|
249
|
+
* @param {Array<{path: string}>} entries
|
|
250
|
+
* @param {object} schema - Parsed values.schema.json.
|
|
251
|
+
* @returns {{accepted: Array, rejected: Array}}
|
|
252
|
+
*/
|
|
253
|
+
function filterEntriesBySchema (entries, schema) {
|
|
254
|
+
const accepted = []
|
|
255
|
+
const rejected = []
|
|
256
|
+
for (const entry of entries) {
|
|
257
|
+
(isPathAllowedBySchema(schema, entry.path) ? accepted : rejected).push(entry)
|
|
258
|
+
}
|
|
259
|
+
return { accepted, rejected }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Neutralize description lines that would otherwise parse as AsciiDoc
|
|
263
|
+
// structure when injected at block level: section titles re-parent the
|
|
264
|
+
// following sections, and an unclosed block delimiter swallows the rest of
|
|
265
|
+
// the document. `{empty}` renders as nothing but demotes the line to a
|
|
266
|
+
// plain paragraph.
|
|
267
|
+
function sanitizeDescription (text) {
|
|
268
|
+
return text
|
|
269
|
+
.split('\n')
|
|
270
|
+
.map((line) => (/^(=+\s|-{2,}\s*$|[=*_.+]{4,}\s*$|\|===)/.test(line) ? `{empty}${line}` : line))
|
|
271
|
+
.join('\n')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Inject extracted entries into a generated AsciiDoc Helm reference.
|
|
276
|
+
*
|
|
277
|
+
* Sections are matched by their `=== link:++<url>?...path=<key>++[<key>]`
|
|
278
|
+
* headings. New sections reuse the Artifact Hub URL prefix of the existing
|
|
279
|
+
* sections and are inserted in alphabetical key order. Keys that are already
|
|
280
|
+
* documented are skipped.
|
|
281
|
+
*
|
|
282
|
+
* @param {string} adoc - The converted AsciiDoc document.
|
|
283
|
+
* @param {Array<{path: string, description: string, default: string}>} entries
|
|
284
|
+
* @returns {{doc: string, injected: string[], sectionsFound: number}}
|
|
285
|
+
*/
|
|
286
|
+
function injectIntoAsciiDoc (adoc, entries) {
|
|
287
|
+
// The label may itself contain brackets (`[storage.volume[0].name]` in the
|
|
288
|
+
// connectors chart), so it is matched to end of line rather than to the
|
|
289
|
+
// first closing bracket.
|
|
290
|
+
const headingRe = /^=== link:\+\+(https?:\/\/[^+]*[?&]path=)([^+]+)\+\+\[.*\]\s*$/gm
|
|
291
|
+
const sections = []
|
|
292
|
+
let match
|
|
293
|
+
while ((match = headingRe.exec(adoc)) !== null) {
|
|
294
|
+
sections.push({ index: match.index, urlPrefix: match[1], key: match[2] })
|
|
295
|
+
}
|
|
296
|
+
if (sections.length === 0) {
|
|
297
|
+
return { doc: adoc, injected: [], sectionsFound: 0 }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const urlPrefix = sections[0].urlPrefix
|
|
301
|
+
// Tracks both keys present in the document and keys queued for injection,
|
|
302
|
+
// so a path extracted twice (for example, from both @doc and # -- comments
|
|
303
|
+
// for the same key) is only injected once.
|
|
304
|
+
const existingKeys = new Set(sections.map((s) => s.key))
|
|
305
|
+
const additions = []
|
|
306
|
+
for (const entry of entries) {
|
|
307
|
+
if (existingKeys.has(entry.path)) continue
|
|
308
|
+
existingKeys.add(entry.path)
|
|
309
|
+
additions.push(entry)
|
|
310
|
+
}
|
|
311
|
+
additions.sort((a, b) => a.path.localeCompare(b.path))
|
|
312
|
+
|
|
313
|
+
let doc = adoc
|
|
314
|
+
const injected = []
|
|
315
|
+
|
|
316
|
+
for (const entry of additions) {
|
|
317
|
+
// Recompute section offsets after each insertion.
|
|
318
|
+
const current = []
|
|
319
|
+
headingRe.lastIndex = 0
|
|
320
|
+
while ((match = headingRe.exec(doc)) !== null) {
|
|
321
|
+
current.push({ index: match.index, key: match[2] })
|
|
322
|
+
}
|
|
323
|
+
const after = current.find((s) => s.key.localeCompare(entry.path) > 0)
|
|
324
|
+
|
|
325
|
+
const section = [
|
|
326
|
+
`=== link:++${urlPrefix}${entry.path}++[${entry.path}]`,
|
|
327
|
+
'',
|
|
328
|
+
sanitizeDescription(entry.description),
|
|
329
|
+
'',
|
|
330
|
+
`*Default:* ${entry.default}`,
|
|
331
|
+
'',
|
|
332
|
+
'',
|
|
333
|
+
].join('\n')
|
|
334
|
+
|
|
335
|
+
if (after) {
|
|
336
|
+
doc = doc.slice(0, after.index) + section + doc.slice(after.index)
|
|
337
|
+
} else {
|
|
338
|
+
// Append after the final section, before the next heading (levels 1-3)
|
|
339
|
+
// if one exists, so a trailing non-values subsection stays last.
|
|
340
|
+
const last = current[current.length - 1]
|
|
341
|
+
const rest = doc.slice(last.index)
|
|
342
|
+
const nextHeading = rest.search(/\n={1,3} /)
|
|
343
|
+
if (nextHeading > 0) {
|
|
344
|
+
const cut = last.index + nextHeading + 1
|
|
345
|
+
doc = doc.slice(0, cut) + section + doc.slice(cut)
|
|
346
|
+
} else {
|
|
347
|
+
doc = doc.trimEnd() + '\n\n' + section
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
injected.push(entry.path)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { doc, injected, sectionsFound: sections.length }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
module.exports = {
|
|
357
|
+
extractCommentedValueDocs,
|
|
358
|
+
injectIntoAsciiDoc,
|
|
359
|
+
filterEntriesBySchema,
|
|
360
|
+
isPathAllowedBySchema,
|
|
361
|
+
}
|
|
@@ -1,37 +1,45 @@
|
|
|
1
|
-
const https = require('https');
|
|
2
1
|
const yaml = require('js-yaml');
|
|
3
2
|
const { spawnSync } = require('child_process');
|
|
3
|
+
const { getGitHubToken } = require('./github-token');
|
|
4
|
+
|
|
5
|
+
const ANTORA_URL = 'https://raw.githubusercontent.com/redpanda-data/docs/main/antora.yml'
|
|
6
|
+
|
|
7
|
+
const TOKEN_HINT = ' The docs repository is private, so set GIT_CREDENTIALS (or GITHUB_TOKEN / REDPANDA_GITHUB_TOKEN / ACTIONS_BOT_TOKEN) so the fetch can authenticate.'
|
|
8
|
+
const REJECTED_HINT = ' A GitHub token was sent but rejected, so it may be expired or not grant access to the docs repository (the default Actions GITHUB_TOKEN is scoped to its own repository).'
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Retrieves the current Self-Managed documentation version from the remote antora.yml file.
|
|
7
12
|
*
|
|
13
|
+
* The docs repository is private, and raw.githubusercontent serves private
|
|
14
|
+
* content as 404 rather than 401 when unauthenticated, so the fetch sends a
|
|
15
|
+
* Bearer token when one is available. It also returns 404 (not 401) for a
|
|
16
|
+
* REJECTED token, so a 404 with a token is retried once without
|
|
17
|
+
* authentication before being treated as real.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} [deps] - Injectable for tests: { fetchImpl, token }.
|
|
8
20
|
* @returns {Promise<string>} Resolves with the version string (for example, "25.1").
|
|
9
21
|
*
|
|
10
22
|
* @throws {Error} If the antora.yml file cannot be fetched, parsed, or if the version field is missing.
|
|
11
23
|
*/
|
|
12
|
-
function fetchRemoteAntoraVersion() {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
})
|
|
33
|
-
}).on('error', reject)
|
|
34
|
-
})
|
|
24
|
+
async function fetchRemoteAntoraVersion(deps = {}) {
|
|
25
|
+
const fetchImpl = deps.fetchImpl || fetch
|
|
26
|
+
const ghToken = 'token' in deps ? deps.token : getGitHubToken()
|
|
27
|
+
|
|
28
|
+
let resp = await fetchImpl(ANTORA_URL, ghToken ? { headers: { Authorization: `Bearer ${ghToken}` } } : undefined)
|
|
29
|
+
const hint = ghToken ? REJECTED_HINT : TOKEN_HINT
|
|
30
|
+
if (resp.status === 404 && ghToken) {
|
|
31
|
+
await resp.body?.cancel().catch(() => {})
|
|
32
|
+
resp = await fetchImpl(ANTORA_URL)
|
|
33
|
+
}
|
|
34
|
+
if (!resp.ok) {
|
|
35
|
+
await resp.body?.cancel().catch(() => {})
|
|
36
|
+
throw new Error(`Failed to fetch antora.yml: ${resp.status}${resp.status === 404 ? hint : ''}`)
|
|
37
|
+
}
|
|
38
|
+
const cfg = yaml.load(await resp.text())
|
|
39
|
+
if (cfg.version == null) {
|
|
40
|
+
throw new Error('version field missing')
|
|
41
|
+
}
|
|
42
|
+
return String(cfg.version).trim()
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/**
|
package/cli-utils/setup-mcp.js
CHANGED
|
@@ -141,7 +141,8 @@ async function setupMCP(options = {}) {
|
|
|
141
141
|
|
|
142
142
|
// Check if configuration matches desired mode
|
|
143
143
|
const isCorrectNpxConfig = mode === 'npx' && existingCommand === 'npx' &&
|
|
144
|
-
existingArgs.includes('doc-tools-mcp')
|
|
144
|
+
existingArgs.includes('doc-tools-mcp') &&
|
|
145
|
+
existingArgs.some(a => a === `--package=${packageName}`);
|
|
145
146
|
const isCorrectLocalConfig = mode === 'local' && existingCommand === 'node' &&
|
|
146
147
|
existingArgs[0] === localPath;
|
|
147
148
|
|
|
@@ -149,7 +150,7 @@ async function setupMCP(options = {}) {
|
|
|
149
150
|
needsUpdate = true;
|
|
150
151
|
console.log(chalk.yellow('⚠ ') + ' MCP server configured but with different setup:');
|
|
151
152
|
console.log(chalk.gray(` Current: ${existingCommand} ${existingArgs.join(' ')}`));
|
|
152
|
-
const newSetup = mode === 'local' ? `node ${localPath}` :
|
|
153
|
+
const newSetup = mode === 'local' ? `node ${localPath}` : `npx -y --package=${packageName} doc-tools-mcp`;
|
|
153
154
|
console.log(chalk.gray(` New: ${newSetup}\n`));
|
|
154
155
|
} else {
|
|
155
156
|
console.log(chalk.green('✓') + ` MCP server already configured correctly\n`);
|
|
@@ -197,7 +198,7 @@ async function setupMCP(options = {}) {
|
|
|
197
198
|
if (mode === 'local') {
|
|
198
199
|
command = `claude mcp add --transport stdio --scope user ${serverName} -- node ${localPath}`;
|
|
199
200
|
} else {
|
|
200
|
-
command = `claude mcp add --transport stdio --scope user ${serverName} -- npx -y doc-tools-mcp`;
|
|
201
|
+
command = `claude mcp add --transport stdio --scope user ${serverName} -- npx -y --package=${packageName} doc-tools-mcp`;
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
// Execute the command
|
package/mcp/USER_GUIDE.adoc
CHANGED
|
@@ -1202,7 +1202,7 @@ claude mcp add --transport stdio --scope user redpanda-docs-tool-assistant -- \
|
|
|
1202
1202
|
[,bash]
|
|
1203
1203
|
----
|
|
1204
1204
|
claude mcp add --transport stdio --scope user redpanda-docs-tool-assistant -- \
|
|
1205
|
-
npx -y doc-tools-mcp
|
|
1205
|
+
npx -y --package=@redpanda-data/docs-extensions-and-macros doc-tools-mcp
|
|
1206
1206
|
----
|
|
1207
1207
|
|
|
1208
1208
|
This adds the MCP server configuration to `~/.claude.json`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redpanda-data/docs-extensions-and-macros",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.11.1",
|
|
4
4
|
"description": "Antora extensions and macros developed for Redpanda documentation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"antora",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"build": "antora --to-dir docs --fetch local-antora-playbook.yml",
|
|
25
25
|
"serve": "wds --node-resolve --open preview/test/ --watch --root-dir docs",
|
|
26
26
|
"test": "jest",
|
|
27
|
+
"test:unit": "jest --config jest.unit.config.js",
|
|
27
28
|
"test:e2e": "jest __tests__/e2e/",
|
|
28
29
|
"test:mcp": "jest __tests__/mcp/",
|
|
29
30
|
"test:mcp:integration": "jest __tests__/mcp/integration.test.js",
|
|
@@ -112,23 +112,9 @@ function parseDisableTable (source) {
|
|
|
112
112
|
return rows
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
/**
|
|
116
|
-
* Extract the enterprise-components attribute list from rp-connect-docs
|
|
117
|
-
* antora.yml, without a YAML dependency on the full file structure.
|
|
118
|
-
*
|
|
119
|
-
* @param {object} antoraConfig - Parsed antora.yml object.
|
|
120
|
-
* @returns {string[]|undefined} The list, or undefined when absent.
|
|
121
|
-
*/
|
|
122
|
-
function extractAntoraEnterpriseComponents (antoraConfig) {
|
|
123
|
-
const attributes = antoraConfig && antoraConfig.asciidoc && antoraConfig.asciidoc.attributes
|
|
124
|
-
const list = attributes && attributes['enterprise-components']
|
|
125
|
-
return Array.isArray(list) ? list.map(String) : undefined
|
|
126
|
-
}
|
|
127
|
-
|
|
128
115
|
module.exports = {
|
|
129
116
|
parseCoreEnum,
|
|
130
117
|
parseEnterpriseProperties,
|
|
131
118
|
parseConnectEnterprisePlugins,
|
|
132
119
|
parseDisableTable,
|
|
133
|
-
extractAntoraEnterpriseComponents,
|
|
134
120
|
}
|
|
@@ -122,22 +122,36 @@ function checkCoreProperties (features, enterpriseProperties, allPropertyNames)
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
125
|
+
* Validate registry connect-plugin pointers against the enterprise plugins
|
|
126
|
+
* in connect's info.csv.
|
|
127
|
+
*
|
|
128
|
+
* This used to diff info.csv against the hand-maintained
|
|
129
|
+
* enterprise-components list in rp-connect-docs antora.yml, but
|
|
130
|
+
* rp-connect-docs#485 removed that list (it had gone stale — the exact drift
|
|
131
|
+
* this validator exists to catch). Per-plugin enterprise status in the docs
|
|
132
|
+
* now comes from the generated connector catalog, which is built from the
|
|
133
|
+
* same connect metadata as info.csv, so it cannot drift by construction.
|
|
134
|
+
* What can still go stale is the registry itself: any entry that pins a
|
|
135
|
+
* specific plugin via `source: {kind: connect-plugin}` must name a plugin
|
|
136
|
+
* that info.csv actually marks enterprise.
|
|
137
|
+
*
|
|
138
|
+
* The check also reports what it verified, so a run with zero pinned
|
|
139
|
+
* plugins is visibly a shallow pass rather than a silent one.
|
|
127
140
|
*/
|
|
128
|
-
function checkConnect (infoCsvPlugins,
|
|
141
|
+
function checkConnect (infoCsvPlugins, features, connectRef) {
|
|
129
142
|
const findings = []
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
143
|
+
const pinned = []
|
|
144
|
+
const source = connectRef ? `info.csv@${connectRef}` : 'info.csv'
|
|
145
|
+
for (const feature of features) {
|
|
146
|
+
if (feature.source && feature.source.kind === 'connect-plugin') {
|
|
147
|
+
const value = String(feature.source.value).trim()
|
|
148
|
+
pinned.push(value)
|
|
149
|
+
if (!infoCsvPlugins.includes(value)) {
|
|
150
|
+
findings.push(finding('error', 'connect-list', `'${feature.name}' points at connect plugin '${value}', which is not an enterprise plugin in ${source} (renamed, removed, or no longer enterprise).`))
|
|
151
|
+
}
|
|
139
152
|
}
|
|
140
153
|
}
|
|
154
|
+
findings.push(finding('info', 'connect-list', `${source} lists ${infoCsvPlugins.length} enterprise plugin(s); ${pinned.length} pinned by registry connect-plugin entries. Individual plugins are documented by the generated connector catalog; the licensing table covers them through the aggregate connect-scope entries.`))
|
|
141
155
|
return findings
|
|
142
156
|
}
|
|
143
157
|
|
|
@@ -200,7 +214,7 @@ function buildMappingPartial (features, enumValues) {
|
|
|
200
214
|
* @param {string} [sources.coreHeader] - enterprise_features.h contents.
|
|
201
215
|
* @param {string} [sources.configurationHeader] - configuration.h contents.
|
|
202
216
|
* @param {string} [sources.infoCsv] - connect info.csv contents.
|
|
203
|
-
* @param {string
|
|
217
|
+
* @param {string} [sources.connectRef] - ref the info.csv was fetched at, for messages.
|
|
204
218
|
* @param {string} [sources.disablePage] - disable-enterprise-features.adoc contents.
|
|
205
219
|
* @param {string[]} [sources.allPropertyNames] - full property list for gating checks.
|
|
206
220
|
* @returns {{findings: object[], features: object[], enumValues: string[]}}
|
|
@@ -223,7 +237,7 @@ function runChecks (sources) {
|
|
|
223
237
|
}
|
|
224
238
|
if (sources.infoCsv) {
|
|
225
239
|
const plugins = parsers.parseConnectEnterprisePlugins(sources.infoCsv)
|
|
226
|
-
findings.push(...checkConnect(plugins, sources.
|
|
240
|
+
findings.push(...checkConnect(plugins, features, sources.connectRef))
|
|
227
241
|
} else {
|
|
228
242
|
findings.push(finding('info', 'connect-list', 'Skipped: no connect info.csv provided.'))
|
|
229
243
|
}
|
|
@@ -2588,6 +2588,10 @@ async function generateRpkDocs(options = {}) {
|
|
|
2588
2588
|
overrides,
|
|
2589
2589
|
outputDir,
|
|
2590
2590
|
cloudSecretDir, // Directory for rpk cloud and rpk security secret commands
|
|
2591
|
+
// Directory for the shared -X -> RPK_* env vars partial. The handler passes
|
|
2592
|
+
// the partials dir it already derived; when absent (direct callers, tests)
|
|
2593
|
+
// it is derived from outputDir the same way.
|
|
2594
|
+
envPartialDir,
|
|
2591
2595
|
rpkVersion,
|
|
2592
2596
|
pluginVersions = {},
|
|
2593
2597
|
draftMissing = false,
|
|
@@ -3155,6 +3159,21 @@ async function generateRpkDocs(options = {}) {
|
|
|
3155
3159
|
navResult = updateNavFile(navFile, commands, resolvedOverrides, topLevelWithSubcommands, effectiveProtectedPlugins)
|
|
3156
3160
|
}
|
|
3157
3161
|
|
|
3162
|
+
// Regenerate the shared -X -> RPK_* env vars partial from the same tree,
|
|
3163
|
+
// so it always matches the rpk build the command pages came from. Trees
|
|
3164
|
+
// from rpk versions that predate x_options (redpanda#31520) skip with a
|
|
3165
|
+
// note; `doc-tools generate rpk-env-partial` covers those via its
|
|
3166
|
+
// -X list fallback.
|
|
3167
|
+
const envPartialResult = writeEnvVarsPartial(
|
|
3168
|
+
tree,
|
|
3169
|
+
envPartialDir || derivePartialsDir(outputDir)
|
|
3170
|
+
)
|
|
3171
|
+
if (envPartialResult.written) {
|
|
3172
|
+
console.log(` Regenerated env vars partial (${envPartialResult.keyCount} -X options): ${envPartialResult.output}`)
|
|
3173
|
+
} else {
|
|
3174
|
+
console.log(' Env vars partial not regenerated: tree has no x_options (rpk predates redpanda#31520)')
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3158
3177
|
return {
|
|
3159
3178
|
commandCount: commands.length,
|
|
3160
3179
|
filesGenerated,
|
|
@@ -3162,12 +3181,55 @@ async function generateRpkDocs(options = {}) {
|
|
|
3162
3181
|
filesFailed,
|
|
3163
3182
|
filesDeleted,
|
|
3164
3183
|
subdirectoriesCreated: createdSubdirs.size,
|
|
3184
|
+
envPartial: envPartialResult,
|
|
3165
3185
|
...navResult
|
|
3166
3186
|
}
|
|
3167
3187
|
}
|
|
3168
3188
|
|
|
3189
|
+
/**
|
|
3190
|
+
* Resolve the partials directory of the Antora module that owns the rpk pages.
|
|
3191
|
+
*
|
|
3192
|
+
* For the docs layout (`.../modules/reference/pages/rpk`) this is the sibling
|
|
3193
|
+
* of `pages`, so `.../modules/reference/partials`. For an arbitrary
|
|
3194
|
+
* `--output-dir` there is no `pages` segment to anchor on, so fall back to a
|
|
3195
|
+
* `partials` directory beside the output. Walking a fixed number of levels up
|
|
3196
|
+
* from outputDir is wrong for arbitrary paths: it escapes the output tree and
|
|
3197
|
+
* can land on an unrelated directory.
|
|
3198
|
+
*
|
|
3199
|
+
* @param {string} outputDir - The rpk pages output directory
|
|
3200
|
+
* @returns {string} Absolute or relative path to the partials directory
|
|
3201
|
+
*/
|
|
3202
|
+
function derivePartialsDir (outputDir) {
|
|
3203
|
+
const normalized = String(outputDir).replace(/\\/g, '/')
|
|
3204
|
+
const pagesIndex = normalized.lastIndexOf('/pages/')
|
|
3205
|
+
if (pagesIndex !== -1) {
|
|
3206
|
+
return path.join(normalized.substring(0, pagesIndex), 'partials')
|
|
3207
|
+
}
|
|
3208
|
+
return path.join(path.dirname(normalized), 'partials')
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
/**
|
|
3212
|
+
* Write the -X -> RPK_* env vars partial from a command tree's x_options.
|
|
3213
|
+
* @param {Object} tree - Parsed rpk command tree
|
|
3214
|
+
* @param {string} partialsDir - Directory the partial is written into
|
|
3215
|
+
* @returns {{written: boolean, keyCount?: number, output?: string}}
|
|
3216
|
+
*/
|
|
3217
|
+
function writeEnvVarsPartial (tree, partialsDir) {
|
|
3218
|
+
if (!Array.isArray(tree.x_options) || tree.x_options.length === 0) {
|
|
3219
|
+
return { written: false }
|
|
3220
|
+
}
|
|
3221
|
+
const { renderPartial, keyToEnvVar } = require('./generate-x-env-partial.js')
|
|
3222
|
+
const options = tree.x_options.map(o => ({ name: o.name, env: o.env || keyToEnvVar(o.name) }))
|
|
3223
|
+
const output = path.join(partialsDir, 'rpk-env-vars.adoc')
|
|
3224
|
+
fs.mkdirSync(path.dirname(output), { recursive: true })
|
|
3225
|
+
fs.writeFileSync(output, renderPartial(options))
|
|
3226
|
+
return { written: true, keyCount: options.length, output }
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3169
3229
|
module.exports = {
|
|
3170
3230
|
generateRpkDocs,
|
|
3231
|
+
writeEnvVarsPartial,
|
|
3232
|
+
derivePartialsDir,
|
|
3171
3233
|
mergeCommandOverrides,
|
|
3172
3234
|
applyOverridesToTree,
|
|
3173
3235
|
resolveReferences,
|