@redpanda-data/docs-extensions-and-macros 5.9.0 → 5.11.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.
package/bin/doc-tools.js CHANGED
@@ -34,7 +34,6 @@ const {
34
34
  // Import other utilities
35
35
  const { determineDocsBranch } = require('../cli-utils/self-managed-docs-branch.js')
36
36
  const fetchFromGithub = require('../tools/fetch-from-github.js')
37
- const { urlToXref } = require('../cli-utils/convert-doc-links.js')
38
37
  const { getAntoraValue, setAntoraValue } = require('../cli-utils/antora-utils')
39
38
 
40
39
  // --------------------------------------------------------------------
@@ -1280,6 +1279,56 @@ automation
1280
1279
  }
1281
1280
  })
1282
1281
 
1282
+ /**
1283
+ * generate rpk-env-partial
1284
+ *
1285
+ * @description
1286
+ * Generates the -X option -> RPK_* environment variable mapping table as an
1287
+ * AsciiDoc partial from rpk's own -X option data, so the table cannot drift
1288
+ * from the CLI. Prefers the structured x_options array in `rpk --print-tree`
1289
+ * (which carries the env var names rpk itself derives) and falls back to
1290
+ * parsing `-X list` text for rpk versions that predate it. Hidden -X options
1291
+ * appear in neither source, so they are excluded automatically.
1292
+ *
1293
+ * The main rpk-docs pipeline also writes this partial from the tree it
1294
+ * already holds; this standalone command is for targeted refreshes without
1295
+ * a full generation run.
1296
+ *
1297
+ * @example
1298
+ * # Generate for a release tag (sparse-clones redpanda, builds rpk with Go)
1299
+ * npx doc-tools generate rpk-env-partial --ref v26.2.1 --output modules/reference/partials/rpk-env-vars.adoc
1300
+ *
1301
+ * # Use a local source checkout (as-is, no checkout changes), an existing
1302
+ * # rpk binary, or a versioned tree snapshot (no clone or build at all)
1303
+ * npx doc-tools generate rpk-env-partial --from-source ~/redpanda --output modules/reference/partials/rpk-env-vars.adoc
1304
+ * npx doc-tools generate rpk-env-partial --rpk-bin "$(command -v rpk)" --output modules/reference/partials/rpk-env-vars.adoc
1305
+ * npx doc-tools generate rpk-env-partial --from-json docs-data/rpk-v26.2.1.json --output modules/reference/partials/rpk-env-vars.adoc
1306
+ */
1307
+ automation
1308
+ .command('rpk-env-partial')
1309
+ .description('Generate the -X -> RPK_* env var mapping partial from rpk -X list output.')
1310
+ .option('-r, --ref <ref>', 'Git branch or tag to build rpk from (e.g., dev, v26.2.1). Clones from GitHub.')
1311
+ .option('--from-source <path>', 'Path to local rpk source (src/go/rpk directory)')
1312
+ .option('--rpk-bin <path>', 'Path to an existing rpk binary (skips clone and build)')
1313
+ .option('--from-json <path>', 'Versioned tree snapshot from docs-data (skips clone and build; requires a snapshot with x_options)')
1314
+ .option('--output <path>', 'Path to write the partial to', 'modules/reference/partials/rpk-env-vars.adoc')
1315
+ .action((options) => {
1316
+ try {
1317
+ const { handleXEnvPartialGeneration } = require('../tools/rpk-docs/generate-x-env-partial.js')
1318
+ const result = handleXEnvPartialGeneration({
1319
+ ref: options.ref,
1320
+ fromSource: options.fromSource,
1321
+ rpkBin: options.rpkBin,
1322
+ fromJson: options.fromJson,
1323
+ output: options.output
1324
+ })
1325
+ console.log(`Wrote ${result.keyCount} -X option mappings to ${result.output} (source: ${result.source})`)
1326
+ } catch (err) {
1327
+ console.error(`Error: ${err.message}`)
1328
+ process.exit(1)
1329
+ }
1330
+ })
1331
+
1283
1332
  /**
1284
1333
  * generate rpk-plugin-stubs
1285
1334
  *
@@ -1677,46 +1726,50 @@ automation
1677
1726
  r = spawnSync('pandoc', [md, '-t', 'asciidoc', '-o', outFile], { stdio: 'inherit' })
1678
1727
  if (r.status !== 0) process.exit(r.status)
1679
1728
 
1680
- let doc = fs.readFileSync(outFile, 'utf8')
1681
- const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g
1682
- doc = doc
1683
- .replace(/(\[\d+\])\]\./g, '$1\\].')
1684
- .replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
1685
- .replace(/^=== +(https?:\/\/[^\[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
1686
- .replace(/^== # (.*)$/gm, '= $1')
1687
- .replace(/^== description: (.*)$/gm, ':description: $1')
1688
- .replace(xrefRe, (match) => {
1689
- let urlPart = match
1690
- let bracketPart = ''
1691
- const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
1692
- if (m) {
1693
- urlPart = m[1]
1694
- bracketPart = m[2]
1729
+ const { formatHelmSpec } = require('../cli-utils/format-helm-spec')
1730
+ let doc = formatHelmSpec(fs.readFileSync(outFile, 'utf8'))
1731
+
1732
+ // helm-docs only renders keys that exist in the YAML tree, so optional
1733
+ // values that ship commented out never appear in the reference. Pull
1734
+ // documented commented-out keys from values.yaml and inject them. This
1735
+ // runs after formatHelmSpec so the injected body sections are not affected
1736
+ // by the blank-line normalization that its header fix performs.
1737
+ const valuesFile = path.join(chartPath, 'values.yaml')
1738
+ if (fs.existsSync(valuesFile)) {
1739
+ const { extractCommentedValueDocs, injectIntoAsciiDoc, filterEntriesBySchema } = require('../cli-utils/helm-commented-values')
1740
+ try {
1741
+ let entries = extractCommentedValueDocs(fs.readFileSync(valuesFile, 'utf8'))
1742
+
1743
+ // Deprecation notices and other prose can produce paths the chart
1744
+ // no longer accepts (for example the removed
1745
+ // storage.tiered.credentialsSecretRef.configurationKey). When the
1746
+ // chart ships a values schema, drop any path it provably rejects.
1747
+ const schemaFile = path.join(chartPath, 'values.schema.json')
1748
+ if (entries.length > 0 && fs.existsSync(schemaFile)) {
1749
+ const schema = JSON.parse(fs.readFileSync(schemaFile, 'utf8'))
1750
+ const { accepted, rejected } = filterEntriesBySchema(entries, schema)
1751
+ if (rejected.length > 0) {
1752
+ console.warn(`Warning: Skipping ${rejected.length} commented-out value(s) rejected by values.schema.json: ${rejected.map((e) => e.path).join(', ')}`)
1753
+ }
1754
+ entries = accepted
1695
1755
  }
1696
- if (urlPart.endsWith('#')) return match
1697
- try {
1698
- const xref = urlToXref(urlPart)
1699
- return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
1700
- } catch (err) {
1701
- console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
1702
- return match
1756
+
1757
+ if (entries.length > 0) {
1758
+ const { doc: withInjected, injected, sectionsFound } = injectIntoAsciiDoc(doc, entries)
1759
+ doc = withInjected
1760
+ if (injected.length > 0) {
1761
+ console.log(`Documented ${injected.length} commented-out value(s): ${injected.join(', ')}`)
1762
+ } else if (sectionsFound === 0) {
1763
+ // Without this, a heading-level change upstream would silently
1764
+ // drop every documented commented-out value from the reference.
1765
+ console.warn(`Warning: No value section headings found in ${name} to anchor commented-out value docs; dropped: ${entries.map((e) => e.path).join(', ')}`)
1766
+ }
1703
1767
  }
1704
- })
1705
- // Antora resolves page metadata with a header-only parse that stops at
1706
- // the first blank line. The chart README templates emit :description:
1707
- // below the title's blank line, so the page shipped the generic site
1708
- // meta description despite carrying one (found on k-connect-helm-spec,
1709
- // DOC-2414 investigation). Relocate the first :description: into the
1710
- // header, directly under the title.
1711
- const descMatch = doc.match(/^:description:[^\n]*$/m)
1712
- if (descMatch) {
1713
- const headerEnd = doc.indexOf('\n\n')
1714
- if (headerEnd !== -1 && doc.indexOf(descMatch[0]) > headerEnd) {
1715
- doc = doc.replace(descMatch[0] + '\n', '')
1716
- doc = doc.replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
1717
- doc = doc.replace(/\n{3,}/g, '\n\n')
1768
+ } catch (err) {
1769
+ console.warn(`Warning: Skipping commented-out value docs for ${name}: ${err.message}`)
1718
1770
  }
1719
1771
  }
1772
+
1720
1773
  fs.writeFileSync(outFile, doc, 'utf8')
1721
1774
 
1722
1775
  console.log(`Done: Wrote ${outFile}`)
@@ -1989,26 +2042,8 @@ automation
1989
2042
  process.exit(1)
1990
2043
  }
1991
2044
 
1992
- let doc = fs.readFileSync(opts.output, 'utf8')
1993
- const xrefRe = /https:\/\/docs\.redpanda\.com[^\s\]\[\)"]+(?:\[[^\]]*\])?/g
1994
- doc = doc.replace(xrefRe, (match) => {
1995
- let urlPart = match
1996
- let bracketPart = ''
1997
- const m = match.match(/^([^\[]+)(\[[^\]]*\])$/)
1998
- if (m) {
1999
- urlPart = m[1]
2000
- bracketPart = m[2]
2001
- }
2002
- if (urlPart.endsWith('#')) return match
2003
- try {
2004
- const xref = urlToXref(urlPart)
2005
- return bracketPart ? `${xref}${bracketPart}` : `${xref}[]`
2006
- } catch (err) {
2007
- console.warn(`⚠️ urlToXref failed on ${urlPart}: ${err.message}`)
2008
- return match
2009
- }
2010
- })
2011
- fs.writeFileSync(opts.output, doc, 'utf8')
2045
+ // docs.redpanda.com URLs are left as-is: the url-to-xref Antora
2046
+ // extension converts them to validated xrefs at site build time.
2012
2047
 
2013
2048
  if (tmpSrc) fs.rmSync(tmpSrc, { recursive: true, force: true })
2014
2049
  fs.rmSync(configTmp, { recursive: true, force: true })
@@ -0,0 +1,58 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Cleans up the AsciiDoc that pandoc produces from a chart's helm-docs README
5
+ * so that it renders correctly as a docs page.
6
+ *
7
+ * docs.redpanda.com URLs are deliberately left as they are: the url-to-xref
8
+ * Antora extension converts them to validated xrefs at site build time.
9
+ */
10
+ function formatHelmSpec (doc) {
11
+ const formatted = doc
12
+ // pandoc escapes footnote-style references inconsistently
13
+ .replace(/(\[\d+\])\]\./g, '$1\\].')
14
+ .replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
15
+ // a section title that is a bare URL has to be a link macro to render
16
+ .replace(/^=== +(https?:\/\/[^[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
17
+ .replace(/^== # (.*)$/gm, '= $1')
18
+ .replace(/^== description: (.*)$/gm, ':description: $1')
19
+ // helm-docs prints each chart dependency's repository verbatim into the
20
+ // Requirements table, and charts in the redpanda-operator monorepo refer
21
+ // to their subcharts by local path (for example the Redpanda chart's
22
+ // console dependency, repository: file://../../console/chart).
23
+ // Asciidoctor auto-links a bare file:// URL, which publishes a link that
24
+ // can never resolve for a reader, so render these as literal code.
25
+ .replace(/(^|[^`])(file:\/\/[^\s|\]`]+)/gm, '$1`$2`')
26
+
27
+ return relocateDescriptionIntoHeader(formatted)
28
+ }
29
+
30
+ /**
31
+ * Moves the first `:description:` attribute up into the document header.
32
+ *
33
+ * Antora resolves page metadata with a header-only parse that stops at the
34
+ * first blank line. The chart README templates emit `:description:` below the
35
+ * title's blank line, so the page shipped the generic site meta description
36
+ * despite carrying one of its own (found on k-connect-helm-spec, DOC-2414).
37
+ *
38
+ * The conversion above turns `== description:` into the attribute but leaves it
39
+ * where pandoc put it, so the move has to happen after it.
40
+ *
41
+ * @param {string} doc - AsciiDoc source.
42
+ * @returns {string} Source with the description in the header.
43
+ */
44
+ function relocateDescriptionIntoHeader (doc) {
45
+ const descMatch = doc.match(/^:description:[^\n]*$/m)
46
+ if (!descMatch) return doc
47
+
48
+ const headerEnd = doc.indexOf('\n\n')
49
+ // Already inside the header, so there is nothing to move.
50
+ if (headerEnd === -1 || doc.indexOf(descMatch[0]) < headerEnd) return doc
51
+
52
+ return doc
53
+ .replace(descMatch[0] + '\n', '')
54
+ .replace(/^(= .+)$/m, `$1\n${descMatch[0]}`)
55
+ .replace(/\n{3,}/g, '\n\n')
56
+ }
57
+
58
+ module.exports = { formatHelmSpec }
@@ -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
+ }
@@ -89,6 +89,10 @@ IMPORTANT: Extensions must be registered under the `antora.extensions` key in yo
89
89
  * **set-available-attachment-versions** - Point UI templates at the newest generated JSON attachments that exist in the catalog
90
90
  * **validate-attributes** - Validate AsciiDoc attributes
91
91
 
92
+ === Link health
93
+ * **url-to-xref** - Convert docs.redpanda.com URLs in content into validated xrefs and warn about URLs that match no published page
94
+ * **external-link-checker** - Verify external URLs respond and report dead links in the build log
95
+
92
96
  === AI-friendly documentation
93
97
  * **convert-to-markdown** - Export documentation pages as markdown with frontmatter
94
98
  * **convert-llms-to-txt** - Generate llms.txt and component-specific exports for AI agents