@redpanda-data/docs-extensions-and-macros 5.54.0 → 5.56.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 (87) hide show
  1. package/bin/doc-tools-mcp.js +4 -5
  2. package/bin/doc-tools.js +354 -9
  3. package/bin/mcp-tools/generated-docs-review.js +26 -9
  4. package/bin/mcp-tools/overrides-audit.js +1 -1
  5. package/bin/mcp-tools/rpcn-docs.js +4 -14
  6. package/cli-utils/docs-links-to-xrefs.js +174 -0
  7. package/cli-utils/format-helm-spec.js +4 -3
  8. package/cli-utils/generated-marker.js +43 -0
  9. package/cli-utils/helm-commented-values.js +125 -21
  10. package/cli-utils/self-managed-docs-branch.js +36 -30
  11. package/extension-utils/html-utils.js +27 -0
  12. package/extension-utils/llms-utils.js +6 -2
  13. package/extension-utils/metadata-candidates.js +304 -0
  14. package/extension-utils/metadata-check.js +174 -0
  15. package/extension-utils/metadata-suggest.js +573 -0
  16. package/extension-utils/taxonomy-categories.js +106 -0
  17. package/extension-utils/taxonomy.js +566 -0
  18. package/extensions/REFERENCE.adoc +110 -8
  19. package/extensions/add-git-dates.js +13 -1
  20. package/extensions/aggregate-terms.js +34 -20
  21. package/extensions/algolia-indexer/generate-index.js +13 -1
  22. package/extensions/algolia-indexer/index.js +4 -2
  23. package/extensions/convert-sitemap-to-markdown.js +47 -12
  24. package/extensions/convert-to-markdown.js +176 -0
  25. package/extensions/generate-fields-only-pages.js +14 -0
  26. package/extensions/modify-connect-tag-playbook.js +181 -3
  27. package/extensions/resolve-xrefs-in-attachments.js +1 -12
  28. package/extensions/url-to-xref.js +92 -22
  29. package/extensions/util/connect-docs-asset.js +6 -5
  30. package/extensions/validate-attributes.js +10 -1
  31. package/extensions/version-fetcher/fetch-latest-docker-tag.js +35 -13
  32. package/extensions/version-fetcher/get-latest-redpanda-helm-version-from-operator.js +64 -40
  33. package/extensions/version-fetcher/list-connect-releases.js +3 -3
  34. package/extensions/version-fetcher/release-lines.js +64 -0
  35. package/extensions/version-fetcher/set-latest-version.js +73 -3
  36. package/macros/README.adoc +1 -0
  37. package/macros/REFERENCE.adoc +99 -0
  38. package/macros/badge.js +5 -8
  39. package/macros/enterprise.js +2 -5
  40. package/macros/glossary.js +4 -14
  41. package/macros/iceberg-explorer.js +242 -0
  42. package/macros/prop.js +3 -13
  43. package/mcp/USER_GUIDE.adoc +25 -135
  44. package/package.json +5 -2
  45. package/tools/check-metadata/index.js +523 -0
  46. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  47. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  48. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  49. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  50. package/tools/dispatch-graph/classify-runs.js +128 -0
  51. package/tools/dispatch-graph/parse-workflows.js +499 -0
  52. package/tools/doc-impact-recording/index.js +190 -0
  53. package/tools/enterprise-features/detect-gating-changes.js +393 -0
  54. package/tools/enterprise-features/verify.js +2 -1
  55. package/tools/gen-rpk-ascii.py +7 -1
  56. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  57. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  58. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  59. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  60. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  61. package/tools/lint-strings/index.js +133 -3
  62. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  63. package/tools/lint-strings/surfaces/connect.js +72 -12
  64. package/tools/lint-strings/surfaces/crd.js +10 -6
  65. package/tools/metrics/metrics.py +11 -0
  66. package/tools/overrides-audit/README.adoc +1 -2
  67. package/tools/overrides-audit/adapters/properties.js +1 -1
  68. package/tools/overrides-audit/index.js +3 -4
  69. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  70. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  71. package/tools/redpanda-connect/AUTOMATION.md +3 -36
  72. package/tools/redpanda-connect/README.adoc +5 -33
  73. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +32 -11
  74. package/tools/redpanda-connect/pr-summary-formatter.js +6 -4
  75. package/tools/redpanda-connect/report-delta.js +6 -3
  76. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +42 -25
  77. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  78. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  79. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  80. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  81. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  82. package/tools/redpanda-release-notes/generate-release-notes.js +116 -1
  83. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  84. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  85. package/tools/rpk-docs/helpers/index.js +4 -1
  86. package/tools/rpk-docs/templates/command.hbs +2 -2
  87. package/tools/overrides-audit/adapters/connect.js +0 -144
@@ -0,0 +1,174 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Converts docs.redpanda.com URLs in a generated page into xrefs before the
5
+ * page is committed to a docs repository.
6
+ *
7
+ * Reference pages generated from engineering sources (the CRD reference from
8
+ * Go doc comments, for example) arrive with absolute docs URLs, because a Go
9
+ * comment cannot carry an AsciiDoc xref. The url-to-xref Antora extension
10
+ * rewrites those at site build time, but the committed source then flips
11
+ * between xrefs and URLs on every regeneration, which buries the real content
12
+ * changes in a review diff. Converting here keeps the committed page stable.
13
+ *
14
+ * The URL-to-resource mapping is the one the extension uses: this module
15
+ * builds a real Antora content catalog from the docs checkout (aggregator plus
16
+ * classifier, worktree files, HEAD only) and hands it to the extension's
17
+ * buildUrlMap and convertContent. Nothing about the URL structure is guessed:
18
+ * a URL is converted only when it matches a page the checkout publishes,
19
+ * directly or through a page alias, and its fragment, if any, matches an
20
+ * anchor on that page; everything else is left as written and reported to the
21
+ * caller.
22
+ *
23
+ * Targets in the checkout's own component are written as module:page xrefs,
24
+ * the form the docs repos use by hand for same-component links. Pages of other
25
+ * components (Connect, Cloud) are not in a single-repo catalog, so URLs that
26
+ * point at them stay raw and are listed in the run output; the build-time
27
+ * extension still converts them on the site.
28
+ */
29
+
30
+ const fs = require('fs')
31
+ const os = require('os')
32
+ const path = require('path')
33
+ const yaml = require('js-yaml')
34
+
35
+ const { buildUrlMap, convertContent } = require('../extensions/url-to-xref')
36
+
37
+ const DEFAULT_HOSTNAMES = ['docs.redpanda.com']
38
+ // Mirrors the extension's default ignore list: docs-domain paths that Antora
39
+ // does not publish (the Bump.sh API reference and the MCP endpoint).
40
+ const DEFAULT_IGNORE = ['^/api/', '^/mcp(/|$)']
41
+ const LATEST_VERSION_SEGMENT = 'current'
42
+
43
+ /**
44
+ * Loads an Antora package, preferring the copy installed in the docs checkout
45
+ * (the site's own Antora version) over the one this package ships for its own
46
+ * preview build. The aggregator and classifier are dependencies of
47
+ * @antora/site-generator, so they are resolved through it.
48
+ */
49
+ function requireAntora (name, searchPaths) {
50
+ const errors = []
51
+ for (const searchPath of searchPaths) {
52
+ try {
53
+ const siteGenerator = path.dirname(require.resolve('@antora/site-generator/package.json', { paths: [searchPath] }))
54
+ return require(require.resolve(name, { paths: [siteGenerator, searchPath] }))
55
+ } catch (err) {
56
+ errors.push(`${searchPath}: ${err.message.split('\n')[0]}`)
57
+ }
58
+ }
59
+ throw new Error(`Cannot load ${name}; install Antora in the docs repository (npm install) so generated links can be converted.\n${errors.join('\n')}`)
60
+ }
61
+
62
+ /**
63
+ * Reads the component name a docs checkout declares in antora.yml. A checkout
64
+ * without one is not an Antora component and cannot be mapped.
65
+ */
66
+ function readComponentName (docsRoot) {
67
+ const descriptor = path.join(docsRoot, 'antora.yml')
68
+ if (!fs.existsSync(descriptor)) {
69
+ throw new Error(`No antora.yml in ${docsRoot}; a docs checkout is needed to map docs URLs to pages`)
70
+ }
71
+ const { name } = yaml.load(fs.readFileSync(descriptor, 'utf8')) || {}
72
+ if (!name) throw new Error(`${descriptor} declares no component name`)
73
+ return name
74
+ }
75
+
76
+ /**
77
+ * Builds the resolver context for a docs checkout: the extension's URL map,
78
+ * computed from an Antora content catalog of the checkout's worktree.
79
+ *
80
+ * @param {string} docsRoot - Root of a docs repository checkout (holds antora.yml).
81
+ * @param {Object} [options]
82
+ * @param {string[]} [options.hostnames] - Docs hostnames to convert.
83
+ * @param {string[]} [options.ignore] - URL path patterns to leave alone silently.
84
+ * @param {string} [options.cacheDir] - Antora cache directory.
85
+ * @returns {Promise<Object>} A context for convertContent, plus `component`
86
+ * (the checkout's component name) and `pageCount`.
87
+ */
88
+ async function buildDocsResolverContext (docsRoot, options = {}) {
89
+ const root = path.resolve(docsRoot)
90
+ const component = readComponentName(root)
91
+ const searchPaths = [root, path.join(__dirname, '..')]
92
+ const aggregateContent = requireAntora('@antora/content-aggregator', searchPaths)
93
+ const classifyContent = requireAntora('@antora/content-classifier', searchPaths)
94
+ const playbook = {
95
+ runtime: {
96
+ quiet: true,
97
+ fetch: false,
98
+ cacheDir: options.cacheDir || fs.mkdtempSync(path.join(os.tmpdir(), 'docs-links-antora-cache-')),
99
+ log: { level: 'silent' },
100
+ },
101
+ site: {},
102
+ // The published shape of the live site; normalizeUrlPath in the extension
103
+ // makes the extension style irrelevant to matching, and candidatePaths
104
+ // covers both the symbolic and the real version segment.
105
+ urls: { htmlExtensionStyle: 'indexify', latestVersionSegment: LATEST_VERSION_SEGMENT },
106
+ // HEAD of the local checkout, read from the worktree, so an unpushed
107
+ // branch and uncommitted pages count as published.
108
+ content: { sources: [{ url: root, branches: 'HEAD' }] },
109
+ }
110
+ const contentCatalog = classifyContent(playbook, await aggregateContent(playbook))
111
+ const pageCount = contentCatalog.getPages((page) => page.out).length
112
+ return Object.assign(buildUrlMap(contentCatalog), {
113
+ hostnames: new Set(options.hostnames || DEFAULT_HOSTNAMES),
114
+ ignore: (options.ignore || DEFAULT_IGNORE).map((pattern) => new RegExp(pattern)),
115
+ latestVersionSegment: LATEST_VERSION_SEGMENT,
116
+ localComponent: component,
117
+ resolveInclude: (spec, page) => contentCatalog.resolveResource(spec, page.src, 'partial', ['partial', 'page']),
118
+ component,
119
+ pageCount,
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Converts the docs URLs in a string. Thin wrapper over the extension's
125
+ * convertContent so callers do not depend on the extension module directly.
126
+ */
127
+ function convertDocsLinks (content, context) {
128
+ return convertContent(content, context)
129
+ }
130
+
131
+ /**
132
+ * Converts the docs URLs in a file in place. The file is rewritten only when
133
+ * something changed.
134
+ *
135
+ * @returns {Object} `{ converted, unmapped, withQueryString, withoutLinkText, missingAnchor }`
136
+ * with the raw URLs in each list deduplicated.
137
+ */
138
+ function convertDocsLinksInFile (filePath, context) {
139
+ const before = fs.readFileSync(filePath, 'utf8')
140
+ const result = convertDocsLinks(before, context)
141
+ if (result.converted) fs.writeFileSync(filePath, result.content, 'utf8')
142
+ return {
143
+ converted: result.converted,
144
+ unmapped: [...new Set(result.unmapped)],
145
+ withQueryString: [...new Set(result.withQueryString)],
146
+ withoutLinkText: [...new Set(result.withoutLinkText)],
147
+ missingAnchor: [...new Set(result.missingAnchor)],
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Formats the outcome of a file conversion for the run output: one summary
153
+ * line, then every URL that was left as written and why.
154
+ */
155
+ function formatDocsLinkReport (result, filePath) {
156
+ const lines = [`Converted ${result.converted} docs URL${result.converted === 1 ? '' : 's'} to xrefs in ${filePath}`]
157
+ const leftRaw = (urls, reason) => {
158
+ if (!urls.length) return
159
+ lines.push(`Left ${urls.length} docs URL${urls.length === 1 ? '' : 's'} as written (${reason}):`)
160
+ for (const url of urls) lines.push(` ${url}`)
161
+ }
162
+ leftRaw(result.unmapped, 'no page in this checkout matches; the url-to-xref extension converts cross-component URLs at build time and warns about broken ones')
163
+ leftRaw(result.withQueryString, 'an xref cannot carry a query string')
164
+ leftRaw(result.withoutLinkText, 'the target page could not be read to check the fragment')
165
+ leftRaw(result.missingAnchor || [], 'anchor not found: the page exists but has no section or anchor with that id')
166
+ return lines.join('\n')
167
+ }
168
+
169
+ module.exports = {
170
+ buildDocsResolverContext,
171
+ convertDocsLinks,
172
+ convertDocsLinksInFile,
173
+ formatDocsLinkReport,
174
+ }
@@ -9,9 +9,10 @@
9
9
  */
10
10
  function formatHelmSpec (doc) {
11
11
  const formatted = doc
12
- // pandoc escapes footnote-style references inconsistently
13
- .replace(/(\[\d+\])\]\./g, '$1\\].')
14
- .replace(/(\[\d+\])\]\]/g, '$1\\]\\]')
12
+ // an array index inside a heading's link text (ingress.hosts[0].host)
13
+ // closes the link text early unless its "]" is escaped
14
+ .replace(/(\[\d+)\]\./g, '$1\\].')
15
+ .replace(/(\[\d+)\]\]/g, '$1\\]]')
15
16
  // a section title that is a bare URL has to be a link macro to render
16
17
  .replace(/^=== +(https?:\/\/[^[]*)\[([^\]]*)\]/gm, '=== link:++$1++[$2]')
17
18
  .replace(/^== # (.*)$/gm, '= $1')
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+
3
+ // The one comment line every doc-tools generator writes at the top of each
4
+ // file it produces. convert-to-markdown reads it and reports the provenance
5
+ // in the Markdown twins (page-generated-content, page-generated-by,
6
+ // page-generated-from, page-generated-sources), so a reader or an agent can
7
+ // tell generated text from hand-written text and knows where a fix goes.
8
+ //
9
+ // The first two sentences are the legacy banner that existing tests and the
10
+ // guard-generated-files check already look for, so they never change.
11
+ //
12
+ // The marker names no doc-tools version. Generated files are committed, so a
13
+ // version in the marker would rewrite the first line of every generated file
14
+ // on every release even when nothing else changed, and every auto-docs PR
15
+ // would carry hundreds of one-line diffs. DOC_TOOLS_VERSION is for provenance
16
+ // recorded at build time (pages generated during the site build), where no
17
+ // file is committed.
18
+ const pkg = require('../package.json')
19
+
20
+ const GENERATED_MARKER_LEGACY = 'This content is autogenerated. Do not edit manually.'
21
+ const DOC_TOOLS_VERSION = pkg.version
22
+
23
+ /**
24
+ * Build the marker line, without a trailing newline.
25
+ * @param {string} generator - Short generator name, for example `property-docs`
26
+ * @param {string} source - Where the text comes from, as a noun phrase, for
27
+ * example `the configuration property definitions in redpanda-data/streaming-enterprise`
28
+ * @param {string} [hint] - How to change the text, as one or more sentences
29
+ * @returns {string} `// This content is autogenerated. ... Generated by doc-tools (<generator>) from <source>. <hint>`
30
+ */
31
+ function generatedMarker (generator, source, hint) {
32
+ if (!generator || !source) throw new Error('generatedMarker needs a generator and a source')
33
+ const parts = [
34
+ GENERATED_MARKER_LEGACY,
35
+ `Generated by doc-tools (${generator}) from ${source.replace(/\.$/, '')}.`,
36
+ ]
37
+ // Handlebars passes its options object as the last argument when a
38
+ // template omits the hint, so only a string counts as one.
39
+ if (typeof hint === 'string' && hint.trim()) parts.push(hint.trim())
40
+ return `// ${parts.join(' ')}`
41
+ }
42
+
43
+ module.exports = { generatedMarker, GENERATED_MARKER_LEGACY, DOC_TOOLS_VERSION }
@@ -24,6 +24,19 @@
24
24
  *
25
25
  * yields `external.domain`.
26
26
  *
27
+ * The description attaches to the first commented-out key after it. When
28
+ * more commented-out siblings follow in the same comment run, each one
29
+ * with its own plain comment above it is documented by that comment:
30
+ *
31
+ * external:
32
+ * # -- Optional domain advertised to external clients.
33
+ * # domain: local
34
+ * # Optional list of advertised addresses.
35
+ * # addresses:
36
+ *
37
+ * yields `external.domain` and `external.addresses`. A sibling with no
38
+ * comment of its own is not documented.
39
+ *
27
40
  * 2. Explicit, fully-qualified syntax that works anywhere in the file:
28
41
  *
29
42
  * # @doc external.addresses -- Optional list of advertised addresses.
@@ -33,6 +46,8 @@
33
46
  * displayed default, which is otherwise `nil`.
34
47
  */
35
48
 
49
+ const { spawnSync } = require('child_process')
50
+
36
51
  // Key names never contain dots or slashes: a dotted or slashed "key" in a
37
52
  // comment is prose or a URL (`# docker.redpanda.com/...:4.101.0`), and
38
53
  // treating it as a key both fabricates a path and truncates the description.
@@ -89,10 +104,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
89
104
  const records = []
90
105
  const stack = [] // enclosing real keys: { name, indent }
91
106
 
92
- // Accumulating comment block: { descLines, default, candidate }. The
93
- // candidate is the key the block documents; emission is deferred to the
94
- // end of the block so that a later key line at the same depth supersedes a
95
- // prose line that merely looks like a key (`# example:`, `# default: 30s`).
107
+ // Accumulating comment block: { marked, descLines, default, candidate }.
108
+ // A marked block opens at a `# --` line. Its description is every comment
109
+ // line up to the first commented-out key, and that key (the candidate) is
110
+ // the one it documents, the same way helm-docs reads a `# --` block as the
111
+ // description of the key directly below it.
112
+ //
113
+ // Optional values often ship as a run of commented-out siblings under one
114
+ // marker, each with its own plain comment above it:
115
+ //
116
+ // # -- Optional domain.
117
+ // # domain: local
118
+ // # Optional list of addresses.
119
+ // # addresses:
120
+ //
121
+ // Once a block has its key, a plain comment line at that key's depth (or
122
+ // shallower) ends it and opens an unmarked block for the next sibling, so
123
+ // `addresses` is documented by its own comment instead of handing its key
124
+ // line to the domain description. An unmarked block is only ever emitted
125
+ // when it has description text and a key; it never becomes a dead marker.
126
+ // The run ends at the first non-comment line, as a marked block does.
96
127
  let block = null
97
128
  let atDoc = null // accumulating @doc entry: { path, descLines, default }
98
129
  let skipScalarIndent = -1 // inside a block scalar when >= 0
@@ -135,6 +166,7 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
135
166
  // The generator drops these; the linter reports them, because their
136
167
  // description silently never ships.
137
168
  const emitDead = (b) => {
169
+ if (!b.marked) return
138
170
  record({
139
171
  kind: 'dead-marker',
140
172
  descLines: b.descLines,
@@ -145,12 +177,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
145
177
  })
146
178
  }
147
179
 
180
+ const openUnmarkedBlock = (line, firstDescLine) => ({
181
+ marked: false,
182
+ markerLine: line,
183
+ lastLine: line,
184
+ descLines: firstDescLine === null ? [] : [firstDescLine],
185
+ default: '',
186
+ annotations: {},
187
+ candidate: null,
188
+ })
189
+
148
190
  // Emit the active block's candidate, if any, and close the block. The
149
191
  // emitted key suppresses its own commented subtree until real content
150
192
  // appears, so nested example structures do not emit bogus paths.
151
193
  const flushBlock = () => {
152
194
  if (!block) return
153
- if (block.candidate) {
195
+ if (block.candidate && !block.marked && !block.descLines.some((l) => l.trim() !== '')) {
196
+ // An unmarked sibling with no comment of its own has nothing to say;
197
+ // still suppress its commented subtree.
198
+ suppressInnerIndent = block.candidate.effIndent
199
+ } else if (block.candidate) {
154
200
  const { name, effIndent } = block.candidate
155
201
  const parents = stack.filter((k) => k.indent < effIndent).map((k) => k.name)
156
202
  record({
@@ -233,13 +279,26 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
233
279
  // "key" is a URL scheme and the "value" is the scheme-relative part.
234
280
  const simpleValue = (value === '' || !/\s/.test(value)) && !value.startsWith('/')
235
281
  if (block && simpleValue) {
236
- if (!block.candidate || effIndent <= block.candidate.effIndent) {
237
- // The last key-shaped line at the block's shallowest depth wins:
238
- // an earlier line at the same depth was prose shaped like a key,
239
- // and deeper lines are nested example structures.
240
- block.candidate = { name: commentedKey[3], effIndent }
282
+ const key = { name: commentedKey[3], effIndent }
283
+ if (!block.candidate || effIndent < block.candidate.effIndent) {
284
+ // The first key line after the description is the documented key.
285
+ // A shallower key replaces it, because the deeper line was a nested
286
+ // example structure rather than the key itself.
287
+ block.candidate = key
288
+ block.lastLine = i
289
+ } else if (effIndent > block.candidate.effIndent) {
290
+ // A nested line of the documented key's commented-out example.
291
+ block.lastLine = i
292
+ } else {
293
+ // A sibling key with no comment of its own. The documented key is
294
+ // complete; the sibling opens an unmarked block, which flushBlock
295
+ // drops because it has no description, while still suppressing
296
+ // the sibling's own commented subtree.
297
+ flushBlock()
298
+ suppressInnerIndent = -1
299
+ block = openUnmarkedBlock(i, null)
300
+ block.candidate = key
241
301
  }
242
- block.lastLine = i
243
302
  continue
244
303
  }
245
304
  }
@@ -248,18 +307,30 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
248
307
  if (descMarker) {
249
308
  flushAtDoc()
250
309
  flushBlock()
251
- block = { markerLine: i, lastLine: i, descLines: [descMarker[1]], default: '', annotations: {}, candidate: null }
310
+ block = { marked: true, markerLine: i, lastLine: i, descLines: [descMarker[1]], default: '', annotations: {}, candidate: null }
252
311
  continue
253
312
  }
254
313
 
255
314
  const comment = line.match(COMMENT_RE)
256
315
  if (comment && line.trim().startsWith('#')) {
257
316
  if (atDoc) { atDoc.descLines.push(comment[1]); atDoc.lastLine = i }
258
- // Description lines precede the documented key; once a candidate
259
- // exists, trailing comment lines belong to its example structure and
260
- // only extend the span.
317
+ // Description lines precede the documented key.
261
318
  else if (block && !block.candidate) { block.descLines.push(comment[1]); block.lastLine = i }
262
- else if (block) block.lastLine = i
319
+ else if (block) {
320
+ // After the key: deeper lines, list items, and bare `#` lines belong
321
+ // to its commented-out example. Prose at the key's own depth or
322
+ // shallower is the description of the next commented-out sibling.
323
+ const [, before, after] = line.match(/^(\s*)#(\s*)/)
324
+ const proseIndent = before.length + Math.max(0, after.length - 1)
325
+ const text = comment[1].trim()
326
+ if (text === '' || text.startsWith('- ') || text === '-' || proseIndent > block.candidate.effIndent) {
327
+ block.lastLine = i
328
+ } else {
329
+ flushBlock()
330
+ suppressInnerIndent = -1
331
+ block = openUnmarkedBlock(i, comment[1])
332
+ }
333
+ }
263
334
  continue
264
335
  }
265
336
 
@@ -270,8 +341,9 @@ function parseValuesFile (yamlText, { attachRealKeys = false } = {}) {
270
341
  flushAtDoc()
271
342
 
272
343
  const realKey = line.match(REAL_KEY_RE)
273
- if (attachRealKeys && realKey && block && !block.candidate && block.lastLine === i - 1) {
274
- // helm-docs' own attachment: the block sits DIRECTLY above a real key.
344
+ if (attachRealKeys && realKey && block && block.marked && !block.candidate && block.lastLine === i - 1) {
345
+ // helm-docs' own attachment: the marked block sits DIRECTLY above a
346
+ // real key.
275
347
  const indent = realKey[1].length
276
348
  while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop()
277
349
  const name = realKey[2].replace(/^"|"$/g, '')
@@ -394,6 +466,31 @@ function filterEntriesBySchema (entries, schema) {
394
466
  return { accepted, rejected }
395
467
  }
396
468
 
469
+ /**
470
+ * Convert Markdown to AsciiDoc with pandoc. `generate helm-spec` uses this for
471
+ * the helm-docs README and for each values.yaml comment it injects, so both
472
+ * get the same conversion. values.yaml comments are Markdown (helm-docs copies
473
+ * them into the README as is), so without it an injected comment's links and
474
+ * emphasis are published as literal Markdown.
475
+ *
476
+ * The `smart` extension is turned off on input. With it, pandoc turns straight
477
+ * quotes into curly ones and writes them as ``text'', which only AsciiDoc
478
+ * Python understood. Asciidoctor prints those backticks and apostrophes
479
+ * literally, so straight quotes are passed through instead.
480
+ *
481
+ * @param {string} markdown - Markdown text.
482
+ * @returns {string} AsciiDoc text, without a trailing newline.
483
+ * @throws {Error} When pandoc cannot be run or exits non-zero.
484
+ */
485
+ function markdownToAsciiDoc (markdown) {
486
+ if (!markdown || !markdown.trim()) return markdown
487
+ const r = spawnSync('pandoc', ['-f', 'markdown-smart', '-t', 'asciidoc'], { input: markdown, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
488
+ if (r.error) throw new Error(`pandoc failed: ${r.error.message}`)
489
+ if (r.status !== 0) throw new Error(`pandoc exited ${r.status}: ${(r.stderr || '').trim()}`)
490
+ if (r.stderr) process.stderr.write(r.stderr)
491
+ return r.stdout.replace(/\s+$/, '')
492
+ }
493
+
397
494
  // Neutralize description lines that would otherwise parse as AsciiDoc
398
495
  // structure when injected at block level: section titles re-parent the
399
496
  // following sections, and an unclosed block delimiter swallows the rest of
@@ -414,11 +511,17 @@ function sanitizeDescription (text) {
414
511
  * sections and are inserted in alphabetical key order. Keys that are already
415
512
  * documented are skipped.
416
513
  *
514
+ * Descriptions and defaults come from values.yaml comments, which are
515
+ * Markdown. Pass `toAsciiDoc` (for example `markdownToAsciiDoc`) to convert
516
+ * them the way the rest of the document was converted. Without it, the text
517
+ * is inserted as is.
518
+ *
417
519
  * @param {string} adoc - The converted AsciiDoc document.
418
520
  * @param {Array<{path: string, description: string, default: string}>} entries
521
+ * @param {{toAsciiDoc?: (markdown: string) => string}} [options]
419
522
  * @returns {{doc: string, injected: string[], sectionsFound: number}}
420
523
  */
421
- function injectIntoAsciiDoc (adoc, entries) {
524
+ function injectIntoAsciiDoc (adoc, entries, { toAsciiDoc = (text) => text } = {}) {
422
525
  // The label may itself contain brackets (`[storage.volume[0].name]` in the
423
526
  // connectors chart), so it is matched to end of line rather than to the
424
527
  // first closing bracket.
@@ -460,9 +563,9 @@ function injectIntoAsciiDoc (adoc, entries) {
460
563
  const section = [
461
564
  `=== link:++${urlPrefix}${entry.path}++[${entry.path}]`,
462
565
  '',
463
- sanitizeDescription(entry.description),
566
+ sanitizeDescription(toAsciiDoc(entry.description)),
464
567
  '',
465
- `*Default:* ${entry.default}`,
568
+ `*Default:* ${toAsciiDoc(entry.default)}`,
466
569
  '',
467
570
  '',
468
571
  ].join('\n')
@@ -492,6 +595,7 @@ module.exports = {
492
595
  parseValuesFile,
493
596
  extractCommentedValueDocs,
494
597
  injectIntoAsciiDoc,
598
+ markdownToAsciiDoc,
495
599
  filterEntriesBySchema,
496
600
  isPathAllowedBySchema,
497
601
  // Exported so the lint surface cannot hold its own copy. Every one of these
@@ -43,51 +43,57 @@ async function fetchRemoteAntoraVersion(deps = {}) {
43
43
  }
44
44
 
45
45
  /**
46
- * Determines the appropriate documentation branch for a given operator tag based on the remote Antora version.
46
+ * Reports whether a branch exists in the docs repository.
47
47
  *
48
- * Normalizes the input tag, extracts the major.minor version, and applies version-specific logic to select the correct branch. Verifies that the chosen branch exists in the remote repository.
48
+ * @param {string} branch - Branch name, such as "main" or "v/25.1".
49
+ * @returns {boolean} True when `git ls-remote` finds the branch.
50
+ */
51
+ function docsBranchExists(branch) {
52
+ const repo = 'https://github.com/redpanda-data/docs.git'
53
+ return spawnSync('git', ['ls-remote', '--exit-code', '--heads', repo, `refs/heads/${branch}`], {
54
+ stdio: 'ignore'
55
+ }).status === 0
56
+ }
57
+
58
+ /**
59
+ * Determines the appropriate documentation branch for a given operator tag or branch based on the remote Antora version.
60
+ *
61
+ * Normalizes the input, extracts the major.minor version, and applies version-specific logic to select the correct branch. Verifies that the chosen branch exists in the remote repository.
62
+ * A ref with no version, such as `main`, `dev`, or a feature branch, holds content for the next release, which the docs repository keeps on `main`.
49
63
  *
50
- * @param {string} operatorTag - The operator tag to evaluate (for example, "operator/v25.1.2" or "v25.1.2").
64
+ * @param {string} operatorRef - The operator tag or branch to evaluate (for example, "operator/v25.1.2", "v25.1.2", "release/v25.1.x", or "main").
65
+ * @param {object} [deps] - Injectable for tests: { antoraVersion, branchExists, fetchImpl, token }.
51
66
  * @returns {Promise<string>} The name of the documentation branch to use.
52
67
  *
53
- * @throws {Error} If the tag cannot be parsed or if the determined branch does not exist in the remote repository.
68
+ * @throws {Error} If the Antora version cannot be fetched or if the determined branch does not exist in the remote repository.
54
69
  */
55
- async function determineDocsBranch(operatorTag) {
56
- // Strip any "operator/" prefix
57
- const TAG = operatorTag.replace(/^(?:operator|release)\//, '')
58
- // Pull in the remote Antora version
59
- const ANTORA = await fetchRemoteAntoraVersion()
70
+ async function determineDocsBranch(operatorRef, deps = {}) {
71
+ const branchExists = deps.branchExists || docsBranchExists
72
+ // Strip any "operator/" or "release/" prefix
73
+ const TAG = operatorRef.replace(/^(?:operator|release)\//, '')
60
74
  // Extract v<major>.<minor>
61
75
  const mm = TAG.match(/^v(\d+\.\d+)/)
62
76
  const filtered = mm ? `v${mm[1]}` : null
63
77
 
64
- if (!filtered) {
65
- throw new Error(`Could not parse major.minor from ${TAG}`)
66
- }
67
- // We started versioning the operator in line with Redpanda core versions. But when v2.4.x was the latest version, the docs were still on 25.1 and v25.1.x of the operator was still in beta. So we need to handle this special case.
68
78
  let branch
69
- if (filtered === 'v2.4') {
70
- if (ANTORA === '25.1') {
79
+ if (!filtered) {
80
+ branch = 'main'
81
+ } else {
82
+ // Pull in the remote Antora version
83
+ const ANTORA = deps.antoraVersion || await fetchRemoteAntoraVersion(deps)
84
+ // We started versioning the operator in line with Redpanda core versions. But when v2.4.x was the latest version, the docs were still on 25.1 and v25.1.x of the operator was still in beta. So we need to handle this special case.
85
+ if (filtered === 'v2.4') {
86
+ branch = ANTORA === '25.1' ? 'main' : 'v/24.3'
87
+ // For all other versions use the v<major>.<minor> branch unless it is the current version, in which case we use 'main'.
88
+ } else if (filtered === `v${ANTORA}`) {
71
89
  branch = 'main'
72
90
  } else {
73
- branch = 'v/24.3'
91
+ branch = `v/${filtered.slice(1)}`
74
92
  }
75
- // For all other versions use the v<major>.<minor> branch unless it is the current version, in which case we use 'main'.
76
- } else if (filtered === `v${ANTORA}`) {
77
- branch = 'main'
78
- } else {
79
- branch = `v/${filtered.slice(1)}`
80
93
  }
81
94
 
82
- // Verify branch exists
83
- const repo = 'https://github.com/redpanda-data/docs.git'
84
- const ref = `refs/heads/${branch}`
85
- const ok = spawnSync('git', ['ls-remote', '--exit-code', '--heads', repo, ref], {
86
- stdio: 'ignore'
87
- }).status === 0
88
-
89
- if (!ok) {
90
- throw new Error(`Docs branch ${branch} not found in ${repo}`)
95
+ if (!branchExists(branch)) {
96
+ throw new Error(`Docs branch ${branch} not found in https://github.com/redpanda-data/docs.git`)
91
97
  }
92
98
 
93
99
  return branch
@@ -0,0 +1,27 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Shared HTML utility functions for extensions and macros.
5
+ */
6
+
7
+ /**
8
+ * Escape a value for safe inclusion in HTML text or inside a double-quoted
9
+ * attribute. Escaping `&` first is what keeps the result idempotent-safe for a
10
+ * single pass; escaping `"` is what stops an author-supplied string from ending
11
+ * the attribute it sits in.
12
+ *
13
+ * @param {*} value - Value to escape. `null` and `undefined` become ''.
14
+ * @returns {string} The escaped string.
15
+ */
16
+ function escapeHtml (value) {
17
+ if (value === null || value === undefined) return ''
18
+ return String(value)
19
+ .replace(/&/g, '&amp;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ }
24
+
25
+ module.exports = {
26
+ escapeHtml,
27
+ }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { decode } = require('html-entities');
4
+
3
5
  /**
4
6
  * Shared utilities for llms.txt generation and markdown processing.
5
7
  * Used by both convert-to-markdown.js and convert-llms-to-txt.js.
@@ -115,9 +117,11 @@ const MAX_INDEX_CHARS = 45000;
115
117
  */
116
118
  const MAX_INDEX_DESCRIPTION_CHARS = 200;
117
119
 
120
+ // Titles and descriptions arrive as converted HTML, so tags are stripped and
121
+ // entities decoded. Decoding comes after stripping: an escaped placeholder
122
+ // such as `&lt;name&gt;` must survive as `<name>`, not be removed as a tag.
118
123
  function oneLine(text) {
119
- return String(text || '')
120
- .replace(/<[^>]+>/g, '')
124
+ return decode(String(text || '').replace(/<[^>]+>/g, ''))
121
125
  .replace(/\s+/g, ' ')
122
126
  .trim();
123
127
  }