@redpanda-data/docs-extensions-and-macros 5.55.0 → 5.56.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.
Files changed (53) hide show
  1. package/bin/doc-tools.js +323 -7
  2. package/cli-utils/docs-links-to-xrefs.js +174 -0
  3. package/cli-utils/generated-marker.js +43 -0
  4. package/cli-utils/helm-commented-values.js +37 -3
  5. package/extension-utils/llms-utils.js +6 -2
  6. package/extension-utils/metadata-candidates.js +304 -0
  7. package/extension-utils/metadata-check.js +174 -0
  8. package/extension-utils/metadata-suggest.js +573 -0
  9. package/extension-utils/taxonomy-categories.js +106 -0
  10. package/extension-utils/taxonomy.js +566 -0
  11. package/extensions/REFERENCE.adoc +90 -4
  12. package/extensions/algolia-indexer/generate-index.js +13 -1
  13. package/extensions/algolia-indexer/index.js +4 -2
  14. package/extensions/convert-sitemap-to-markdown.js +47 -12
  15. package/extensions/convert-to-markdown.js +176 -0
  16. package/extensions/generate-fields-only-pages.js +14 -0
  17. package/extensions/modify-connect-tag-playbook.js +2 -2
  18. package/extensions/url-to-xref.js +92 -22
  19. package/extensions/validate-attributes.js +10 -1
  20. package/package.json +2 -1
  21. package/tools/check-metadata/index.js +523 -0
  22. package/tools/cloud-regions/cloud-regions-table-adoc.hbs +1 -2
  23. package/tools/cloud-regions/cloud-regions-table-md.hbs +2 -2
  24. package/tools/cloud-regions/render-cloud-regions.js +3 -0
  25. package/tools/dispatch-graph/check-dispatch-graph.js +790 -0
  26. package/tools/dispatch-graph/classify-runs.js +128 -0
  27. package/tools/dispatch-graph/parse-workflows.js +499 -0
  28. package/tools/enterprise-features/detect-gating-changes.js +440 -0
  29. package/tools/enterprise-features/verify.js +2 -1
  30. package/tools/gen-rpk-ascii.py +7 -1
  31. package/tools/lint-strings/fixtures/connect/lint_connect.go +8 -0
  32. package/tools/lint-strings/fixtures/crd/lint_types.go +4 -1
  33. package/tools/lint-strings/fixtures/rpk-pr461/delete.base.go +42 -0
  34. package/tools/lint-strings/fixtures/rpk-pr461/delete.head.go +45 -0
  35. package/tools/lint-strings/fixtures/rpk-pr461/reviewer-comments.json +1 -0
  36. package/tools/lint-strings/index.js +133 -3
  37. package/tools/lint-strings/rules/verbatim-asciidoc.js +166 -1
  38. package/tools/lint-strings/surfaces/connect.js +72 -12
  39. package/tools/lint-strings/surfaces/crd.js +10 -6
  40. package/tools/metrics/metrics.py +11 -0
  41. package/tools/property-extractor/generate-handlebars-docs.js +9 -1
  42. package/tools/property-extractor/helpers/applyPropertyLinks.js +35 -0
  43. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +29 -8
  44. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -24
  45. package/tools/redpanda-connect/templates/bloblang-function.hbs +1 -1
  46. package/tools/redpanda-connect/templates/descriptions-partials.hbs +1 -1
  47. package/tools/redpanda-connect/templates/examples-partials.hbs +1 -1
  48. package/tools/redpanda-connect/templates/fields-partials.hbs +2 -2
  49. package/tools/redpanda-connect/templates/metadata-partials.hbs +1 -1
  50. package/tools/rpk-docs/generate-rpk-docs.js +2 -0
  51. package/tools/rpk-docs/generate-x-env-partial.js +7 -4
  52. package/tools/rpk-docs/helpers/index.js +4 -1
  53. package/tools/rpk-docs/templates/command.hbs +1 -1
@@ -60,6 +60,9 @@ function rulesFor (surface) {
60
60
  * @param {Set<string>} [options.reviewedFingerprints] - Diff mode: skip
61
61
  * declarations (and removals) whose fingerprint is in this set, so a PR
62
62
  * review sees each string once across pushes
63
+ * @param {string[]} [options.reviewerPassages] - Diff mode: passages from
64
+ * human review comments. A changed line found in one counts as written by
65
+ * a reviewer, and a declaration whose changed lines all are is skipped
63
66
  * @param {Function} [options.log] - Progress logger (stderr by default)
64
67
  * @returns {Object} { findings, summary, unsupported_surfaces }
65
68
  */
@@ -71,6 +74,7 @@ function lintStrings (options) {
71
74
  skipRules = [],
72
75
  onlyRules = null,
73
76
  reviewedFingerprints = null,
77
+ reviewerPassages = null,
74
78
  log = (msg) => process.stderr.write(`${msg}\n`)
75
79
  } = options
76
80
 
@@ -93,6 +97,8 @@ function lintStrings (options) {
93
97
  const reviewed = reviewedFingerprints || new Set()
94
98
  const pending = []
95
99
  let alreadyReviewed = 0
100
+ const reviewerWritten = []
101
+ const headLines = new Map()
96
102
  let removal = { rawFiles: [], declarations: [] }
97
103
  const skipped = []
98
104
  let unverifiable = 0
@@ -138,6 +144,17 @@ function lintStrings (options) {
138
144
  alreadyReviewed++
139
145
  continue
140
146
  }
147
+ if (reviewerPassages && reviewerPassages.length) {
148
+ const changed = changedLinesOf(repoPath, decl, files.get(decl.file), headLines)
149
+ const own = passagesInDeclaration(decl, reviewerPassages)
150
+ if (changed.length > 0 && changed.every((l) => writtenByReviewer(l.text, own))) {
151
+ // No wording review, but the published-content check still
152
+ // sees it: a reviewer's wording can describe a behavior change
153
+ // that makes another page wrong.
154
+ reviewerWritten.push(pendingEntry(decl))
155
+ continue
156
+ }
157
+ }
141
158
  declarations.push(decl)
142
159
  if (decl.meta && decl.meta.unverifiable) unverifiable++
143
160
  const detail = surface.reviewContext ? surface.reviewContext(decl) : null
@@ -181,6 +198,8 @@ function lintStrings (options) {
181
198
  if (diffBase) {
182
199
  merged.declarations = pending
183
200
  merged.summary.alreadyReviewed = alreadyReviewed
201
+ merged.summary.reviewerWritten = reviewerWritten.length
202
+ merged.summary.reviewerWrittenDeclarations = reviewerWritten
184
203
  }
185
204
  merged.summary.removedDeclarations = removal.declarations.map(pendingEntry)
186
205
  // Declarations whose text has gaps the extractor could not evaluate
@@ -468,6 +487,9 @@ function formatHuman (result) {
468
487
  if (summary.alreadyReviewed) {
469
488
  lines.push(`Already reviewed on an earlier push (skipped): ${summary.alreadyReviewed}`)
470
489
  }
490
+ if (summary.reviewerWritten) {
491
+ lines.push(`Text a human reviewer suggested on the PR (skipped): ${summary.reviewerWritten}`)
492
+ }
471
493
  for (const decl of summary.removedDeclarations || []) {
472
494
  lines.push(`Removed or renamed: ${decl.name} (${decl.surface}, ${decl.file})`)
473
495
  }
@@ -520,6 +542,102 @@ function readFingerprints (file) {
520
542
  return new Set(text.split(/[\s,]+/).filter((token) => /^[0-9a-f]{16}$/.test(token)))
521
543
  }
522
544
 
545
+ // Shorter passages are labels, lead-ins ("Suggest replacing this with:") or
546
+ // fragments that occur in many strings, so matching on them would skip
547
+ // declarations nobody reviewed.
548
+ const MIN_REVIEWER_PASSAGE = 40
549
+
550
+ function normalizeText (text) {
551
+ return String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
552
+ }
553
+
554
+ /**
555
+ * Passages from human review comments that a declaration can be matched
556
+ * against: the body of every ```suggestion block, and every prose paragraph
557
+ * outside a code block. Whitespace is collapsed so a paragraph the author
558
+ * re-wrapped to the file's line length still matches.
559
+ */
560
+ function reviewerPassagesFrom (bodies) {
561
+ const passages = new Set()
562
+ for (const body of Array.isArray(bodies) ? bodies : []) {
563
+ if (typeof body !== 'string') continue
564
+ const chunks = [...body.matchAll(/```suggestion[^\n]*\n([\s\S]*?)```/g)].map((m) => m[1])
565
+ chunks.push(...body.replace(/```[\s\S]*?(```|$)/g, '\n\n').split(/\n\s*\n/))
566
+ for (const chunk of chunks) {
567
+ const text = normalizeText(chunk)
568
+ if (text.length >= MIN_REVIEWER_PASSAGE) passages.add(text)
569
+ }
570
+ }
571
+ return [...passages]
572
+ }
573
+
574
+ // A changed line shorter than this ("per topic.", "For example,") occurs in
575
+ // too many paragraphs to say who wrote it, so it never counts as a
576
+ // reviewer's. That errs toward reviewing.
577
+ const MIN_REVIEWER_LINE = 12
578
+
579
+ /**
580
+ * The lines this PR changed inside a declaration's span, as HEAD has them.
581
+ * Lines with no letters or digits (a closing backtick, a blank line) say
582
+ * nothing about who wrote the string and are left out.
583
+ */
584
+ function changedLinesOf (repoPath, decl, lineSet, cache) {
585
+ if (!lineSet) return []
586
+ if (!cache.has(decl.file)) {
587
+ let lines = null
588
+ try {
589
+ lines = fs.readFileSync(path.join(repoPath, decl.file), 'utf8').split('\n')
590
+ } catch {}
591
+ cache.set(decl.file, lines)
592
+ }
593
+ const lines = cache.get(decl.file)
594
+ if (!lines) return []
595
+ const out = []
596
+ for (const [start, end] of spansOf(decl)) {
597
+ if (start == null || end == null) continue
598
+ for (let n = start; n <= end; n++) {
599
+ if (!lineSet.has(n) || n > lines.length) continue
600
+ const text = normalizeText(lines[n - 1])
601
+ if (/[A-Za-z0-9]/.test(text)) out.push({ line: n, text })
602
+ }
603
+ }
604
+ return out
605
+ }
606
+
607
+ /**
608
+ * The passages a declaration contains whole. A reviewer's proposed wording
609
+ * lands in the string as written, but a comment that quotes the string to
610
+ * criticize it ("Please change this: ...") carries a lead-in the string
611
+ * does not have, so it never counts as the reviewer writing the string.
612
+ */
613
+ function passagesInDeclaration (decl, passages) {
614
+ const forms = [normalizeText(decl.string), normalizeText(decl.declaration_text)].filter(Boolean)
615
+ return passages.filter((passage) => forms.some((form) => form.includes(passage)))
616
+ }
617
+
618
+ /**
619
+ * True when a human reviewer wrote this line on the PR: it appears whole in
620
+ * one of their passages. Matching per line, not per string, means an author
621
+ * who keeps a reviewer's paragraph and rewrites the next one still gets the
622
+ * new paragraph reviewed (streaming-enterprise PR 461 is the case this
623
+ * exists for: the review argued with help text applied from a reviewer).
624
+ */
625
+ function writtenByReviewer (line, passages) {
626
+ return line.length >= MIN_REVIEWER_LINE && passages.some((passage) => passage.includes(line))
627
+ }
628
+
629
+ /**
630
+ * Read a --reviewer-comments file: a JSON array of comment bodies. A missing
631
+ * or unparseable file yields no passages, so it means "review everything".
632
+ */
633
+ function readReviewerComments (file) {
634
+ try {
635
+ return reviewerPassagesFrom(JSON.parse(fs.readFileSync(file, 'utf8')))
636
+ } catch {
637
+ return []
638
+ }
639
+ }
640
+
523
641
  /**
524
642
  * CLI entry point shared by bin/doc-tools.js and direct invocation
525
643
  * (node tools/lint-strings --repo <path> ...).
@@ -536,7 +654,8 @@ function runCli (options) {
536
654
  diffBase: options.diff || null,
537
655
  skipRules: options.skipRules ? String(options.skipRules).split(',').map((s) => s.trim()).filter(Boolean) : [],
538
656
  onlyRules: options.onlyRules ? String(options.onlyRules).split(',').map((s) => s.trim()).filter(Boolean) : null,
539
- reviewedFingerprints: options.reviewed ? readFingerprints(options.reviewed) : null
657
+ reviewedFingerprints: options.reviewed ? readFingerprints(options.reviewed) : null,
658
+ reviewerPassages: options.reviewerComments ? readReviewerComments(options.reviewerComments) : null
540
659
  })
541
660
  } catch (err) {
542
661
  console.error(`Error: ${err.message}`)
@@ -555,7 +674,7 @@ function runCli (options) {
555
674
  process.exitCode = options.strict && result.summary.errors > 0 ? 1 : 0
556
675
  }
557
676
 
558
- module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor, fingerprint, readFingerprints, pageContext }
677
+ module.exports = { lintStrings, formatHuman, runCli, SURFACES, rulesFor, fingerprint, readFingerprints, reviewerPassagesFrom, readReviewerComments, pageContext }
559
678
 
560
679
  // Direct usage: node tools/lint-strings --repo <path> [--surface a,b]
561
680
  // [--diff <base>] [--format json|human] [--strict]
@@ -571,10 +690,21 @@ if (require.main === module) {
571
690
  else if (arg === '--skip-rules') options.skipRules = args[++i]
572
691
  else if (arg === '--only-rules') options.onlyRules = args[++i]
573
692
  else if (arg === '--reviewed') options.reviewed = args[++i]
693
+ else if (arg === '--reviewer-comments') {
694
+ // A present flag with no operand must not act like an absent one, or
695
+ // `--reviewer-comments --strict` would read --strict as the file and
696
+ // quietly turn strict mode off.
697
+ const operand = args[i + 1]
698
+ if (!operand || operand.startsWith('-')) {
699
+ console.error('--reviewer-comments requires a file path')
700
+ process.exit(2)
701
+ }
702
+ options.reviewerComments = args[++i]
703
+ }
574
704
  else if (arg === '--strict') options.strict = true
575
705
  else {
576
706
  console.error(`Unknown argument: ${arg}`)
577
- console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--reviewed <file>] [--strict]')
707
+ console.error('Usage: node tools/lint-strings --repo <path> [--surface a,b] [--diff <base>] [--format json|human] [--skip-rules a,b] [--only-rules a,b] [--reviewed <file>] [--reviewer-comments <file>] [--strict]')
578
708
  process.exit(2)
579
709
  }
580
710
  }
@@ -105,4 +105,169 @@ const VERBATIM_ASCIIDOC_RULES = [
105
105
  }
106
106
  ]
107
107
 
108
- module.exports = { VERBATIM_ASCIIDOC_RULES, KNOWN_ATTRIBUTES, MACRO_NAMES, countRawPipes }
108
+ /**
109
+ * AsciiDoc preprocessor conditionals in doc strings that publish to two
110
+ * audiences. Connect spec strings are published on the self-managed Connect
111
+ * docs and on the Redpanda Cloud docs, which set the `env-cloud` attribute,
112
+ * and each Connect binary renders the same strings as Markdown with the
113
+ * attribute set only in the Cloud binaries. An author who wraps text whose
114
+ * behavior differs in Cloud has one convention:
115
+ *
116
+ * - `env-cloud` is the only attribute;
117
+ * - block form only: `ifdef::env-cloud[]` or `ifndef::env-cloud[]` alone
118
+ * on a line at column 0, the whole paragraph inside, `endif::[]` alone on
119
+ * a line, a blank line before the opening directive and after the closing
120
+ * one (unless the string starts or ends there);
121
+ * - nothing after the closing bracket, not even whitespace: the Markdown
122
+ * converter in the Connect binaries only treats a line ending in `]` as
123
+ * a directive;
124
+ * - never the single-line form `ifndef::env-cloud[text]`;
125
+ * - never in a Summary, which renders as one line, or in an example
126
+ * summary;
127
+ * - never inside a code fence;
128
+ * - never `ifndef::env-cloud[]` inside `ifdef::env-cloud[]` or the
129
+ * reverse, whose inner text renders for neither audience.
130
+ *
131
+ * Links to pages the other site lacks are handled by the renderers, so a
132
+ * conditional is only for behavior that differs. Not part of
133
+ * VERBATIM_ASCIIDOC_RULES: a surface that publishes to both audiences opts
134
+ * in (connect does); the other verbatim surfaces carry audience conditionals
135
+ * as data, not in the string.
136
+ */
137
+ const CONDITIONAL_ATTRIBUTE = 'env-cloud'
138
+ const CONDITIONAL_WHY = 'The Cloud docs and the Cloud binaries set env-cloud, and the text inside must read as a whole paragraph in both outputs. See docs/README.md in the connect repo.'
139
+ const CONDITIONAL_TOKEN = /\b(?:ifdef|ifndef|ifeval|endif)::/
140
+ // A directive on its own line: optional indent (reported), kind, attribute
141
+ // list, bracketed text, then optional trailing whitespace (reported).
142
+ const CONDITIONAL_LINE = /^(\s*)(ifdef|ifndef|ifeval|endif)::([^\s[\]]*)\[(.*)\](\s*)$/
143
+ // Listing and literal delimiters, and Markdown fences with an optional
144
+ // language tag (```yaml). Tracked so a directive inside one is reported; the
145
+ // preprocessor still evaluates it there.
146
+ const CODE_FENCE = /^(?:(-{4,}|\.{4,})|(`{3,})([\w.+#-]*))[ \t]*$/
147
+
148
+ /**
149
+ * Which summary a declaration is, or null. Summaries render as one line, and
150
+ * connect's docs test rejects a conditional in any JSON `summary` key, which
151
+ * includes example summaries.
152
+ */
153
+ function summaryKind (decl) {
154
+ const meta = decl.meta || {}
155
+ if (meta.kind === 'summary' || (meta.kind === 'bloblang' && meta.part === 'summary')) return 'a Summary'
156
+ if (meta.kind === 'example-summary' || meta.kind === 'bloblang-example') return 'an example summary'
157
+ return null
158
+ }
159
+
160
+ /**
161
+ * Advance the code-fence state over one fence line. An AsciiDoc block closes
162
+ * only on a delimiter of the same character and length; a Markdown fence
163
+ * closes on a bare fence at least as long as the opener.
164
+ */
165
+ function stepFence (fence, match) {
166
+ const [, adoc, ticks, lang] = match
167
+ if (fence === null) return adoc ? { char: adoc[0], length: adoc.length } : { char: '`', length: ticks.length }
168
+ if (adoc) return fence.char === adoc[0] && fence.length === adoc.length ? null : fence
169
+ return fence.char === '`' && !lang && ticks.length >= fence.length ? null : fence
170
+ }
171
+
172
+ const CONDITIONAL_RULE = {
173
+ name: 'conditional',
174
+ description: 'AsciiDoc conditional that is not a well-formed env-cloud block',
175
+ severity: 'warning',
176
+ check: (decl) => {
177
+ const text = decl.string || ''
178
+ if (!text || !CONDITIONAL_TOKEN.test(text)) return []
179
+ // CLI help is not AsciiDoc; the surface marks those declarations.
180
+ if (decl.convention && decl.convention.verbatim_asciidoc === false) return []
181
+ const issues = []
182
+ const say = (what, severity) => issues.push({ message: `${what} ${CONDITIONAL_WHY}`, ...(severity ? { severity } : {}) })
183
+
184
+ const summary = summaryKind(decl)
185
+ if (summary) {
186
+ say(`Contains an AsciiDoc conditional in ${summary}. A summary renders as one line, so it cannot hold a conditional block; keep the summary the same for both audiences and put the differing paragraph in the Description.`)
187
+ return issues
188
+ }
189
+
190
+ const lines = text.split('\n')
191
+ const open = []
192
+ let fence = null
193
+ for (let i = 0; i < lines.length; i++) {
194
+ const line = lines[i]
195
+ const fenceMatch = line.match(CODE_FENCE)
196
+ if (fenceMatch) {
197
+ fence = stepFence(fence, fenceMatch)
198
+ continue
199
+ }
200
+ const m = line.match(CONDITIONAL_LINE)
201
+ if (!m) {
202
+ if (CONDITIONAL_TOKEN.test(line)) {
203
+ say(`"${line.trim().slice(0, 60)}" is not a directive on a line of its own at column 0. Put ifdef::env-cloud[], ifndef::env-cloud[] and endif::[] each on their own line with nothing before them.`)
204
+ }
205
+ continue
206
+ }
207
+ const [, indent, kind, attribute, body, trailing] = m
208
+ const label = `${kind}::${attribute}[${body}]`
209
+ // What is between the brackets decides how Asciidoctor reads the line.
210
+ const bracket = body === '' ? 'empty' : body.trim() === '' ? 'whitespace' : 'text'
211
+ if (fence !== null) {
212
+ // Reported on its own: the form checks below assume prose around
213
+ // the block, and a fence line is not prose.
214
+ say(`${label} is inside a code fence. Keep conditionals out of code fences; wrap the whole paragraph or block instead.`)
215
+ continue
216
+ }
217
+ if (indent) {
218
+ // Asciidoctor and the Connect Markdown converter both read an
219
+ // indented directive as text, so it neither opens nor closes a block
220
+ // here either: the never-closed and no-matching-opener errors below
221
+ // then report the block it leaves unbalanced.
222
+ say(`${label} is indented. A directive starts at column 0, or AsciiDoc and the Connect binaries treat it as text and it does not open or close a block.`)
223
+ continue
224
+ }
225
+ if (trailing) {
226
+ say(`${label} has trailing whitespace after the closing bracket. The Connect binaries only treat a line ending in ] as a directive, so CLI readers would see the directive and the text for the other audience. Remove everything after the ].`, 'error')
227
+ }
228
+
229
+ if (kind === 'ifeval') {
230
+ say(`${label} uses ifeval. env-cloud is the only attribute a doc string may test, with ifdef::env-cloud[] or ifndef::env-cloud[].`)
231
+ open.push({ label, line: i })
232
+ continue
233
+ }
234
+ if (kind === 'endif') {
235
+ if (attribute && attribute !== CONDITIONAL_ATTRIBUTE) say(`${label} names "${attribute}". env-cloud is the only attribute; close the block with endif::[].`)
236
+ if (bracket !== 'empty') {
237
+ // Asciidoctor rejects an endif with anything in its brackets and
238
+ // leaves the block open, so it does not close one here either: the
239
+ // never-closed error below then reports the block it leaves open.
240
+ say(`${label} has ${bracket === 'text' ? 'text' : 'whitespace'} inside its brackets. Asciidoctor rejects it and leaves the block open, so the text after it is hidden from one audience. Write endif::[] with nothing between the brackets.`, 'error')
241
+ } else if (open.length === 0) say(`${label} has no matching ifdef::env-cloud[] or ifndef::env-cloud[] before it.`, 'error')
242
+ else open.pop()
243
+ if (i < lines.length - 1 && lines[i + 1].trim() !== '') say(`${label} needs a blank line after it, so the paragraph inside ends where the block ends.`)
244
+ continue
245
+ }
246
+ // ifdef / ifndef
247
+ if (attribute !== CONDITIONAL_ATTRIBUTE) say(`${label} tests "${attribute}". env-cloud is the only attribute a doc string may test.`)
248
+ if (bracket === 'whitespace') {
249
+ // Asciidoctor reads any bracket content, whitespace included, as the
250
+ // single-line form, which opens no block: the text that follows shows
251
+ // for both audiences. So it is not pushed as an opener, and the endif
252
+ // that follows reports no matching opener.
253
+ say(`${label} has whitespace inside its brackets. Asciidoctor then reads it as the single-line form, which opens no block, so the text after it shows for both audiences. Write ${kind}::${attribute}[] with nothing between the brackets.`, 'error')
254
+ } else if (bracket === 'text') {
255
+ say(`${label} is the single-line form. Use the block form: the directive alone on its line, the whole paragraph inside, then endif::[] on its own line.`)
256
+ } else {
257
+ if (attribute === CONDITIONAL_ATTRIBUTE) {
258
+ const opposite = kind === 'ifdef' ? 'ifndef' : 'ifdef'
259
+ const outer = open.find((o) => o.kind === opposite && o.attribute === CONDITIONAL_ATTRIBUTE)
260
+ if (outer) say(`${label} is inside ${outer.label}, so the text between them renders for neither audience. Close the outer block with endif::[] before opening the other one.`)
261
+ }
262
+ open.push({ label, kind, attribute, line: i })
263
+ }
264
+ if (i > 0 && lines[i - 1].trim() !== '') say(`${label} needs a blank line before it, so the paragraph inside starts where the block starts.`)
265
+ }
266
+ for (const { label } of open) {
267
+ say(`${label} is never closed with endif::[]. Everything after it is hidden from one audience, or the rest of the page is.`, 'error')
268
+ }
269
+ return issues
270
+ }
271
+ }
272
+
273
+ module.exports = { VERBATIM_ASCIIDOC_RULES, CONDITIONAL_RULE, KNOWN_ATTRIBUTES, MACRO_NAMES, countRawPipes }
@@ -8,6 +8,7 @@ const { SourceCache } = require('../source-text')
8
8
  const { splitTopLevelArgs, findBalancedClose, collectGoFiles } = require('../go-source')
9
9
  const { GoIndex, statementEnd, straightLineReturn, stripStrings, renderResult, isResolved, unresolvedParts } = require('../go-index')
10
10
  const { parseLiteralFields } = require('./rpk')
11
+ const { CONDITIONAL_RULE } = require('../rules/verbatim-asciidoc')
11
12
 
12
13
  /**
13
14
  * Connect surface: every published string a Redpanda Connect build defines.
@@ -18,6 +19,11 @@ const { parseLiteralFields } = require('./rpk')
18
19
  * the option descriptions of annotated enum fields.
19
20
  * - Bloblang plugins (bloblang.PluginSpec): Description, Example summaries,
20
21
  * and parameter descriptions.
22
+ * - benthos's internal field API (internal/docs): the description argument
23
+ * of docs.FieldString, docs.FieldObject, docs.FieldBool and the other
24
+ * docs.Field constructors, which document the shared config blocks
25
+ * (http_server, tls, logger, batching, ...) that benthos builds outside
26
+ * the public service API.
21
27
  * - `rpk connect` help (urfave/cli): command Usage, UsageText, ArgsUsage and
22
28
  * Description, and flag Usage.
23
29
  *
@@ -87,6 +93,17 @@ const BARE_FIELD_CTORS = new Set([
87
93
 
88
94
  const SERVICE_PKG = 'github.com/redpanda-data/benthos/v4/public/service'
89
95
  const BLOBLANG_PKG = 'github.com/redpanda-data/benthos/v4/public/bloblang'
96
+ const DOCS_PKG = 'github.com/redpanda-data/benthos/v4/internal/docs'
97
+
98
+ // internal/docs constructors with the signature
99
+ // (name, description string, examples ...any). docs.FieldComponent takes
100
+ // no description and is left out.
101
+ const DOCS_FIELD_CTORS = [
102
+ 'FieldAnything', 'FieldObject', 'FieldString', 'FieldInterpolatedString', 'FieldBloblang',
103
+ 'FieldInt', 'FieldFloat', 'FieldBool', 'FieldURL',
104
+ 'FieldInput', 'FieldProcessor', 'FieldOutput', 'FieldBuffer', 'FieldCache',
105
+ 'FieldRateLimit', 'FieldMetrics', 'FieldTracer', 'FieldScanner'
106
+ ]
90
107
  const CLI_PKG = /^github\.com\/urfave\/cli(?:\/v\d+)?$/
91
108
 
92
109
  // Repos whose urfave/cli commands are `rpk connect` help.
@@ -331,6 +348,7 @@ class ConnectScanner {
331
348
  file._aliases = {
332
349
  service: aliasesFor(file, SERVICE_PKG),
333
350
  bloblang: aliasesFor(file, BLOBLANG_PKG),
351
+ docs: aliasesFor(file, DOCS_PKG),
334
352
  cli: aliasesFor(file, CLI_PKG)
335
353
  }
336
354
  }
@@ -804,6 +822,7 @@ class ConnectScanner {
804
822
  const skipped = []
805
823
  const { service, bloblang, cli } = this.aliases(file)
806
824
  if (service.size > 0 || bloblang.size > 0) this.scanSpecs(file, rel, declarations, skipped)
825
+ this.scanDocsFields(file, rel, declarations)
807
826
  if (cli.size > 0 && this.cli) this.scanCli(file, rel, declarations)
808
827
  return { declarations, skipped }
809
828
  }
@@ -1055,6 +1074,53 @@ class ConnectScanner {
1055
1074
  this.scanJsFunctions(file, rel, declarations)
1056
1075
  }
1057
1076
 
1077
+ /**
1078
+ * benthos's internal field API: `docs.FieldString(name, description)`
1079
+ * and the other internal/docs constructors. The description is the second
1080
+ * argument, not a `.Description()` call, so scanSpecs never sees it.
1081
+ * Inside internal/docs itself the constructors are unqualified. An empty
1082
+ * description is skipped: internal/docs uses `FieldString("type", "")`
1083
+ * for structural fields that no page documents.
1084
+ */
1085
+ scanDocsFields (file, rel, declarations) {
1086
+ const { docs } = this.aliases(file)
1087
+ const mod = this.index.gomod.module
1088
+ const inPackage = Boolean(mod) && `${mod}/${this.index.rel(file.dir).split(path.sep).join('/')}` === DOCS_PKG
1089
+ if (docs.size === 0 && !inPackage) return
1090
+ const qual = docs.size > 0 ? `(?:${[...docs].map(escapeRe).join('|')})\\s*\\.\\s*` : ''
1091
+ const prefix = inPackage ? (qual ? `(?:${qual})?` : '') : qual
1092
+ const pattern = new RegExp(`(^|[^\\w.])${prefix}(${DOCS_FIELD_CTORS.join('|')})\\s*\\(`, 'g')
1093
+ const masked = file.masked
1094
+ // String contents blanked, so example YAML or prose that mentions a
1095
+ // constructor never reads as a call.
1096
+ const text = stripStrings(masked)
1097
+ let m
1098
+ while ((m = pattern.exec(text)) !== null) {
1099
+ const start = m.index + m[1].length
1100
+ if (/\bfunc\s*$/.test(text.slice(Math.max(0, start - 10), start))) continue
1101
+ const open = m.index + m[0].length - 1
1102
+ const close = file.pairs.get(open)
1103
+ if (close === undefined) continue
1104
+ const args = splitTopLevelArgs(masked.slice(open + 1, close))
1105
+ if (args.length < 2) continue
1106
+ const argStart = (i) => open + 1 + args.slice(0, i).reduce((n, a) => n + a.length + 1, 0)
1107
+ const result = this.evalAt(file, args[1], argStart(1))
1108
+ if (isResolved(result) && renderResult(result).trim() === '') continue
1109
+ const nameResult = this.evalAt(file, args[0], argStart(0))
1110
+ this.pushDecl(declarations, file, rel, {
1111
+ kind: 'field',
1112
+ name: isResolved(nameResult) ? renderResult(nameResult) : null,
1113
+ nameResult,
1114
+ nameExpr: { text: args[0], offset: argStart(0) },
1115
+ result,
1116
+ valueExpr: { text: args[1], offset: argStart(1) },
1117
+ start,
1118
+ end: close,
1119
+ extra: { ctor: `docs.${m[2]}`, ...this.componentMeta(file, start) }
1120
+ })
1121
+ }
1122
+ }
1123
+
1058
1124
  /**
1059
1125
  * javascript processor functions: `registerVMRunnerFunction(name,
1060
1126
  * description).Param(name, type, what)`. The processor renders each one
@@ -1272,7 +1338,7 @@ class ConnectScanner {
1272
1338
  extra: {
1273
1339
  literal: literal === 'Command' ? 'command' : literal,
1274
1340
  // rpk-docs formats CLI help like cobra help; it is not AsciiDoc.
1275
- skip_rules: ['raw-pipe', 'unknown-attribute', 'broken-macro', 'too-short']
1341
+ skip_rules: ['raw-pipe', 'unknown-attribute', 'broken-macro', 'conditional', 'too-short']
1276
1342
  }
1277
1343
  }))
1278
1344
  }
@@ -1460,8 +1526,6 @@ const MARKUP_PATTERNS = [
1460
1526
  [/\n/, 'a line break']
1461
1527
  ]
1462
1528
 
1463
- const ENV_CONDITIONAL = /\b(?:ifdef|ifndef|ifeval|endif)::/
1464
-
1465
1529
  // Modules of the Redpanda Connect docs component. A generated partial is
1466
1530
  // rendered on the self-managed page and included into Cloud pages, so a
1467
1531
  // link needs an explicit module to resolve in both places.
@@ -1491,15 +1555,11 @@ const RULES = [
1491
1555
  return [{ message: `ShortDescription contains ${found.join(', ')}. Form-based config editors, such as the one in Redpanda Cloud, show it as plain text, so markup renders literally. Write one plain sentence and keep markup in Description().` }]
1492
1556
  }
1493
1557
  },
1494
- {
1495
- name: 'connect-env-conditional',
1496
- description: 'AsciiDoc ifdef/ifndef conditional in a Go spec string',
1497
- severity: 'warning',
1498
- check: (decl) => {
1499
- if (!decl.string || !ENV_CONDITIONAL.test(decl.string)) return []
1500
- return [{ message: 'Contains an AsciiDoc conditional (ifdef::, ifndef:: or endif::). The docs generator owns every Cloud conditional in the partials, and a hand-written one nests inside them, so the Cloud copy shows or hides the wrong text. Fix the availability data in internal/plugins/info.csv instead, or ask the docs team to carry the sentence on the page.' }]
1501
- }
1502
- },
1558
+ // Spec strings publish to the self-managed and the Cloud docs, and the
1559
+ // Cloud binaries render them with env-cloud set, so an env-cloud block
1560
+ // conditional is the supported way to vary a paragraph between the two.
1561
+ // The rule checks the form; see rules/verbatim-asciidoc.js.
1562
+ CONDITIONAL_RULE,
1503
1563
  {
1504
1564
  name: 'connect-xref-module',
1505
1565
  description: 'xref without a module, or to a module the Connect docs do not have',
@@ -405,18 +405,22 @@ const RULES = [
405
405
  },
406
406
  {
407
407
  name: 'go-field-name-first',
408
- description: 'Description leads with the Go field name instead of the YAML key',
408
+ description: 'Description opens with the Go field name or the YAML key instead of what the field does',
409
409
  severity: 'warning',
410
410
  check: (decl) => {
411
411
  const text = (decl.string || '').trim()
412
412
  if (!text) return []
413
413
  const firstWord = (text.match(/^[A-Za-z0-9_]+/) || [null])[0]
414
414
  if (!firstWord) return []
415
- if (firstWord === decl.meta.go_name &&
416
- decl.meta.go_name.toLowerCase() !== decl.meta.json_name.toLowerCase()) {
417
- return [{ message: `Description starts with the Go field name "${decl.meta.go_name}", but users type "${decl.meta.json_name}" in YAML. Describe the json key: "${decl.meta.json_name}" (or start with what the field does).` }]
418
- }
419
- return []
415
+ // Case-sensitive on purpose: `RetentionTime is...` on
416
+ // json:"retentionTime" opens with the Go name, and `retentionTime
417
+ // is...` opens with the key. Neither says what the field does.
418
+ const { go_name: goName, json_name: jsonName } = decl.meta
419
+ let opener = null
420
+ if (firstWord === goName) opener = `the Go field name "${goName}"`
421
+ else if (firstWord === jsonName) opener = `the YAML key "${jsonName}"`
422
+ if (!opener) return []
423
+ return [{ message: `Description opens with ${opener}. Start with what the field does, for example "Specifies ...". Don't open with the Go field name "${goName}" or the YAML key "${jsonName}".` }]
420
424
  }
421
425
  }
422
426
  ]
@@ -23,6 +23,16 @@ These output files are stored in a versioned folder under `docs/autogenerated/<v
23
23
 
24
24
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
25
25
 
26
+
27
+ def generated_marker(generator, source, hint=""):
28
+ """Same marker shape as cli-utils/generated-marker.js (no version, so a
29
+ release does not rewrite every file); convert-to-markdown reads it."""
30
+ marker = (
31
+ "// This content is autogenerated. Do not edit manually. "
32
+ f"Generated by doc-tools ({generator}) from {source.rstrip('.')}."
33
+ )
34
+ return marker + (f" {hint.strip()}" if hint.strip() else "")
35
+
26
36
  def fetch_metrics(url):
27
37
  """Fetch metrics from the given URL."""
28
38
  try:
@@ -123,6 +133,7 @@ def parse_metrics(metrics_text):
123
133
  def output_asciidoc(metrics, adoc_file):
124
134
  """Output metrics as AsciiDoc."""
125
135
  with open(adoc_file, "w") as f:
136
+ f.write(generated_marker("metrics-docs", "the metrics Redpanda exposes, defined in redpanda-data/streaming-enterprise") + "\n\n")
126
137
  for name, data in metrics.items():
127
138
  f.write(f"=== {name}\n\n")
128
139
  f.write(f"{data['description']}\n\n")
@@ -2,10 +2,18 @@
2
2
  // writer already has open. The previous pointer was a Confluence page, and
3
3
  // Confluence is retired, so it sent every writer who read this line to a dead
4
4
  // end. A repo-relative path cannot rot the same way.
5
- const AUTOGEN_NOTICE = '// This content is autogenerated. Do not edit manually. To change a description, add a link, or scope text to one docs build, edit docs-data/property-overrides.json (fields are documented in docs-data/property-overrides.schema.json and docs-data/README.adoc) and regenerate.\n';
6
5
  'use strict';
7
6
 
8
7
  const fs = require('fs');
8
+ const { generatedMarker } = require('../../cli-utils/generated-marker');
9
+
10
+ // The marker's shape is shared by every generator and read by the
11
+ // convert-to-markdown extension, which exports it to the Markdown twins.
12
+ const AUTOGEN_NOTICE = generatedMarker(
13
+ 'property-docs',
14
+ 'the configuration property definitions in redpanda-data/streaming-enterprise',
15
+ 'To change a description, add a link, or scope text to one docs build, edit docs-data/property-overrides.json (fields are documented in docs-data/property-overrides.schema.json and docs-data/README.adoc) and regenerate.'
16
+ ) + '\n';
9
17
  const bigIntJson = require('../../cli-utils/big-int-json');
10
18
  const path = require('path');
11
19
  const handlebars = require('handlebars');
@@ -419,6 +419,39 @@ function normalizeIncludes(propName, raw, warnings) {
419
419
  return out;
420
420
  }
421
421
 
422
+ /**
423
+ * Remove the inline copies of `includes` that the attachment write-back appended.
424
+ *
425
+ * generate-handlebars-docs.js writes each include back into the description
426
+ * on disk, so the attachment's tooltip still renders it, and leaves `includes`
427
+ * in place. A run that reads that file again (a reused gen/<tag>-properties.json,
428
+ * or generate-docs run twice without re-extraction) sees the directive in the
429
+ * description and in `includes`, and the template rendered it twice. Only the
430
+ * exact shapes the write-back produces are
431
+ * removed, so an include a human put in the prose is left alone.
432
+ *
433
+ * @param {string|undefined} description - Flattened description.
434
+ * @param {Array<{target: string, cloud_only?: boolean, self_managed_only?: boolean}>} items - Normalized includes.
435
+ * @returns {string|undefined}
436
+ */
437
+ function stripInlineIncludes(description, items) {
438
+ if (typeof description !== 'string' || !description.includes('include::')) return description;
439
+ let text = description;
440
+ // The write-back appends in order, so peel from the end.
441
+ for (const item of [...items].reverse()) {
442
+ const directive = `include::${item.target}`;
443
+ let block = directive;
444
+ if (item.cloud_only) block = `ifdef::env-cloud[]\n${directive}\nendif::[]`;
445
+ else if (item.self_managed_only) block = `ifndef::env-cloud[]\n${directive}\nendif::[]`;
446
+ if (text.endsWith(`\n\n${block}`)) {
447
+ text = text.slice(0, -(block.length + 2));
448
+ } else if (text === block) {
449
+ text = '';
450
+ }
451
+ }
452
+ return text;
453
+ }
454
+
422
455
  /**
423
456
  * Why the Cloud build cannot reach a link target, or null when it can.
424
457
  *
@@ -471,6 +504,7 @@ function applyPropertyLinks(properties) {
471
504
  }
472
505
  if (prop.includes !== undefined) {
473
506
  prop.includes = normalizeIncludes(propName, prop.includes, warnings);
507
+ prop.description = stripInlineIncludes(prop.description, prop.includes);
474
508
  }
475
509
  if (Array.isArray(prop.admonitions)) {
476
510
  prop.admonitions = prop.admonitions.filter((item) => {
@@ -589,3 +623,4 @@ module.exports.applyLinksToText = applyLinksToText;
589
623
  module.exports.renderLink = renderLink;
590
624
  module.exports.cloudUnreachable = cloudUnreachable;
591
625
  module.exports.normalizeIncludes = normalizeIncludes;
626
+ module.exports.stripInlineIncludes = stripInlineIncludes;