@redpanda-data/docs-extensions-and-macros 5.56.0 → 5.57.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
@@ -2132,6 +2132,11 @@ automation
2132
2132
  * bullets — it is a CANDIDATE for the curation step that rewrites voice, adds
2133
2133
  * `Area::` labels, normalizes units, and phrases CVEs.
2134
2134
  *
2135
+ * In phase 1, a backticked name that matches a property in the release
2136
+ * series' redpanda-properties-<tag>.json becomes a `prop:<name>[link=true]` link. The
2137
+ * file is found in modules/reference/attachments/ unless --properties-file
2138
+ * names one. With no file, names stay monospace and a warning is logged.
2139
+ *
2135
2140
  * @why
2136
2141
  * Release notes are the one reference surface whose source of truth is
2137
2142
  * engineer-authored prose rather than code, so the deterministic part
@@ -2164,6 +2169,7 @@ automation
2164
2169
  .option('--section-file <file>', 'Phase 2: a curated section (from the sm-release-notes skill) to insert (relative to repo root, must stay inside the repository)')
2165
2170
  .option('--date <date>', 'Authored release date as YYYY-MM-DD (required with --body; confirmed on the PR)')
2166
2171
  .option('--page <file>', 'Target release-notes page (relative to repo root, must stay inside the repository)', 'modules/reference/pages/releases/redpanda.adoc')
2172
+ .option('--properties-file <file>', 'Phase 1: property JSON whose names become prop: links (relative to repo root, must stay inside the repository). Default: the newest redpanda-properties-<tag>.json in modules/reference/attachments/ for the tag\'s release series')
2167
2173
  .option('--section-only', 'Phase 1 only: print the candidate section and do not read or modify the page')
2168
2174
  .option('--dry-run', 'Print output to stdout instead of writing the page')
2169
2175
  .action(async (options) => {
@@ -2172,6 +2178,11 @@ automation
2172
2178
  buildReleaseSection,
2173
2179
  isGaTag,
2174
2180
  } = require('../tools/redpanda-release-notes/generate-release-notes.js')
2181
+ const {
2182
+ ATTACHMENTS_DIR,
2183
+ findPropertiesFile,
2184
+ loadPropertyNames,
2185
+ } = require('../tools/redpanda-release-notes/property-names.js')
2175
2186
  try {
2176
2187
  const repoRoot = findRepoRoot()
2177
2188
 
@@ -2192,6 +2203,19 @@ automation
2192
2203
  return abs
2193
2204
  }
2194
2205
 
2206
+ // Phase 1 links known property names. An explicit --properties-file must
2207
+ // exist; a missing default only means names stay monospace.
2208
+ const readPropertyNames = () => {
2209
+ const file = options.propertiesFile
2210
+ ? readInside(options.propertiesFile, '--properties-file')
2211
+ : findPropertiesFile(path.join(repoRoot, ATTACHMENTS_DIR), options.tag)
2212
+ if (!file) {
2213
+ console.warn(`[release-notes] WARN: no property file for ${options.tag} in ${ATTACHMENTS_DIR}; property names stay monospace.`)
2214
+ return undefined
2215
+ }
2216
+ return loadPropertyNames(file)
2217
+ }
2218
+
2195
2219
  // Phase-1 candidate preview: emit the candidate for the curation step. No
2196
2220
  // page is read or written — this is the LLM step's input, nothing else.
2197
2221
  if (options.sectionOnly) {
@@ -2203,7 +2227,9 @@ automation
2203
2227
  return
2204
2228
  }
2205
2229
  const body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
2206
- const section = buildReleaseSection({ body, version: options.tag, date: options.date })
2230
+ const section = buildReleaseSection({
2231
+ body, version: options.tag, date: options.date, propertyNames: readPropertyNames(),
2232
+ })
2207
2233
  process.stdout.write(section)
2208
2234
  return
2209
2235
  }
@@ -2213,6 +2239,7 @@ automation
2213
2239
  const genArgs = { tag: options.tag, date: options.date, pageContent }
2214
2240
  if (hasBody) {
2215
2241
  genArgs.body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
2242
+ genArgs.propertyNames = readPropertyNames()
2216
2243
  } else {
2217
2244
  genArgs.section = fs.readFileSync(readInside(options.sectionFile, '--section-file'), 'utf8')
2218
2245
  }
@@ -3055,9 +3082,10 @@ programCli
3055
3082
  .requiredOption('--repo <path>', 'Path to a Redpanda core checkout. Nothing is cloned.')
3056
3083
  .requiredOption('--diff <base>', 'Base ref: changes in <base>...HEAD are inspected')
3057
3084
  .option('--format <format>', 'Output format: json or human', 'json')
3085
+ .option('--exclude <file>', 'File of already-dispatched findings, one "name change_kind" per line, to leave out of the report')
3058
3086
  .action((options) => {
3059
3087
  const { spawnSync } = require('child_process')
3060
- const { detectGatingChanges, buildDocImpact, enterprisePropertyNames, ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE } = require('../tools/enterprise-features/detect-gating-changes')
3088
+ const { detectGatingChanges, buildDocImpact, withoutSent, enterprisePropertyNames, ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE } = require('../tools/enterprise-features/detect-gating-changes')
3061
3089
  const empty = { findings: [] }
3062
3090
  const result = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', `${options.diff}...HEAD`, '--', ENUM_HEADER, CONFIG_HEADER, CONFIG_SOURCE], {
3063
3091
  cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024
@@ -3072,7 +3100,14 @@ programCli
3072
3100
  const header = spawnSync('git', ['show', `HEAD:${CONFIG_HEADER}`], { cwd: options.repo, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
3073
3101
  if (header.status !== 0) console.error(`git show HEAD:${CONFIG_HEADER} failed in ${options.repo}; configuration.cc changes are not checked: ${(header.stderr || '').trim()}`)
3074
3102
  const enterpriseNames = header.status === 0 ? enterprisePropertyNames(header.stdout) : []
3075
- const findings = detectGatingChanges(result.stdout, { enterpriseNames })
3103
+ let findings = detectGatingChanges(result.stdout, { enterpriseNames })
3104
+ if (options.exclude) {
3105
+ try {
3106
+ findings = withoutSent(findings, fs.readFileSync(options.exclude, 'utf8'))
3107
+ } catch (err) {
3108
+ console.error(`Could not read ${options.exclude}; nothing excluded: ${err.message}`)
3109
+ }
3110
+ }
3076
3111
  const report = buildDocImpact(findings) || empty
3077
3112
  if (options.format === 'human') {
3078
3113
  if (findings.length === 0) { console.log('No enterprise gating changes.'); return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.56.0",
3
+ "version": "5.57.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -26,10 +26,13 @@
26
26
  * so a test can hand it a saved patch; the CLI wrapper runs git.
27
27
  *
28
28
  * Detection is by added and removed lines, paired by name, so a value that
29
- * merely moves within the enum is not reported. Both signals the header
30
- * carries for an enum value are used (the member line inside the enum, and
31
- * the `case license_required_feature::<name>:` line in format_to), so a
32
- * hunk header that happens not to name the enum still detects.
29
+ * merely moves within the enum is not reported. Only the member line inside
30
+ * the enum counts; `case license_required_feature::<name>:` labels are
31
+ * ignored, because they move whenever the formatter is rewritten.
32
+ *
33
+ * Not covered: node properties (src/v/config/node_config.h, where
34
+ * `fips_mode` is gated) are not scanned, and a restricted value that names a
35
+ * constant defined elsewhere is not seen (see restrictedValueChanges).
33
36
  *
34
37
  * Each finding names ONE affected page, the licensing overview. The docs-site
35
38
  * receiver falls back to matching a finding with no name match against open
@@ -54,15 +57,20 @@ const REGISTRY_URL = 'https://github.com/redpanda-data/docs/blob/main/shared/mod
54
57
  function splitFiles (diffText) {
55
58
  const files = []
56
59
  let current = null
60
+ let inHunk = false
57
61
  for (const raw of String(diffText || '').split('\n')) {
58
62
  const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(raw)
59
63
  if (m) {
60
64
  current = { path: m[2], lines: [] }
61
65
  files.push(current)
66
+ inHunk = false
62
67
  continue
63
68
  }
64
69
  if (!current) continue
65
- if (/^(\+\+\+|---|index |new file|deleted file|similarity|rename|old mode|new mode)/.test(raw)) continue
70
+ // File header lines come before the first hunk. Inside a hunk, a removed
71
+ // `--x;` or an added `++x;` reads as `---x;` or `+++x;` and is content.
72
+ if (raw.startsWith('@@')) inHunk = true
73
+ if (!inHunk && /^(\+\+\+|---|index |new file|deleted file|similarity|rename|old mode|new mode)/.test(raw)) continue
66
74
  current.lines.push(raw)
67
75
  }
68
76
  return files
@@ -92,7 +100,7 @@ function enumChanges (lines) {
92
100
  const sign = line[0]
93
101
  if (sign !== '+' && sign !== '-') continue
94
102
  const target = sign === '+' ? added : removed
95
- const member = inEnum && /^\s*([a-z][a-z0-9_]*)\s*,?\s*(\/\/.*)?$/.exec(body)
103
+ const member = inEnum && /^\s*([a-z][a-z0-9_]*)\s*(?:=\s*[^,/]+?)?\s*,?\s*(\/\/.*)?$/.exec(body)
96
104
  if (member) target.add(member[1])
97
105
  }
98
106
  return {
@@ -107,6 +115,8 @@ function enumChanges (lines) {
107
115
  // at a `;`, and also at a brace, an access specifier or a preprocessor line,
108
116
  // so a declaration never absorbs the line before it.
109
117
  const ENTERPRISE_DECL = /^\s*(?:config::)?enterprise<[\s\S]*>\s*([a-z][a-z0-9_]*)\s*;\s*$/
118
+ // Any templated member declaration, wrapped or not (`property<bool> x;`).
119
+ const ANY_DECL = /^\s*[\w:]+<[\s\S]*>\s*([a-z][a-z0-9_]*)\s*;\s*$/
110
120
 
111
121
  // Comments and attributes are dropped before joining: a `/* ... */` or
112
122
  // `[[attr]]` line above a declaration has no `;` or brace, so it would
@@ -186,24 +196,29 @@ function enterprisePropertyNames (headerText) {
186
196
  * configuration.h. A declaration changed in place shows as a -/+ pair with
187
197
  * the same name; the wrapper is on exactly one side. Each side of each hunk
188
198
  * (context plus its own changed lines) is joined into statements, so a
189
- * declaration that wraps across lines is read whole.
190
- * @returns {{added: string[], removed: string[]}}
199
+ * declaration that wraps across lines is read whole. A wrapped declaration
200
+ * that is removed and declared nowhere on the new side, wrapped or not, is
201
+ * `deleted`: the property is gone, not ungated.
202
+ * @returns {{added: string[], removed: string[], deleted: string[]}}
191
203
  */
192
204
  function enterprisePropertyChanges (lines) {
193
205
  const added = new Set()
194
206
  const removed = new Set()
195
- const collect = (side, target) => {
207
+ const declaredNew = new Set()
208
+ const collect = (side, target, declared) => {
196
209
  for (const st of joinStatements(side)) {
197
210
  if (!st.changed) continue
198
211
  const m = ENTERPRISE_DECL.exec(st.text)
199
212
  if (m) target.add(m[1])
213
+ const any = declared && ANY_DECL.exec(st.text)
214
+ if (any) declared.add(any[1])
200
215
  }
201
216
  }
202
217
  let oldSide = []
203
218
  let newSide = []
204
219
  const flushHunk = () => {
205
220
  collect(oldSide, removed)
206
- collect(newSide, added)
221
+ collect(newSide, added, declaredNew)
207
222
  oldSide = []
208
223
  newSide = []
209
224
  }
@@ -216,9 +231,11 @@ function enterprisePropertyChanges (lines) {
216
231
  else if (sign === ' ') { oldSide.push({ text }); newSide.push({ text }) }
217
232
  }
218
233
  flushHunk()
234
+ const gone = [...removed].filter((n) => !added.has(n))
219
235
  return {
220
236
  added: [...added].filter((n) => !removed.has(n)).sort(),
221
- removed: [...removed].filter((n) => !added.has(n)).sort()
237
+ removed: gone.filter((n) => declaredNew.has(n)).sort(),
238
+ deleted: gone.filter((n) => !declaredNew.has(n)).sort()
222
239
  }
223
240
  }
224
241
 
@@ -253,6 +270,14 @@ function restrictedValueChanges (lines, enterpriseNames) {
253
270
  const body = line.slice(1)
254
271
  const opens = /^\s*,\s*([a-z][a-z0-9_]*)\s*\(/.exec(body)
255
272
  if (opens) { init = opens[1]; changed = false; continue }
273
+ // `*this, <value>, "name",` on one line carries the whole restricted
274
+ // argument list.
275
+ const oneLine = /^\s*\*this\s*,.*"([a-z][a-z0-9_]*)"\s*,/.exec(body)
276
+ if (oneLine && enterpriseNames.has(oneLine[1])) {
277
+ if (sign !== ' ') found.add(oneLine[1])
278
+ changed = false
279
+ continue
280
+ }
256
281
  if (/^\s*\*this\s*,/.test(body)) { changed = sign !== ' '; continue }
257
282
  // The name literal closes the restricted arguments. A string literal
258
283
  // that is neither this initializer's name nor an enterprise property
@@ -307,7 +332,7 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
307
332
  })
308
333
  }
309
334
  } else if (file.path === CONFIG_HEADER) {
310
- const { added, removed } = enterprisePropertyChanges(file.lines)
335
+ const { added, removed, deleted } = enterprisePropertyChanges(file.lines)
311
336
  for (const name of added) {
312
337
  findings.push({
313
338
  surface: 'enterprise-property',
@@ -326,6 +351,15 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
326
351
  summary: `\`${name}\` is no longer wrapped in \`config::enterprise<>\` in \`${CONFIG_HEADER}\`, so it no longer needs a license. The registry entry that names it (${REGISTRY_URL}) and its row on ${DISABLE_PAGE} need to change.`
327
352
  })
328
353
  }
354
+ for (const name of deleted) {
355
+ findings.push({
356
+ surface: 'enterprise-property',
357
+ name,
358
+ change_kind: 'enterprise-property-removed',
359
+ affected_pages: [LICENSING_OVERVIEW],
360
+ summary: `\`${name}\`, a \`config::enterprise<>\` property, was removed from \`${CONFIG_HEADER}\`. From the first release that contains this change the property no longer exists, so its row on ${DISABLE_PAGE} goes and the registry entry that names it (${REGISTRY_URL}) needs a new source pointer or a note, never deletion: a published external name stays in the registry.`
361
+ })
362
+ }
329
363
  } else if (file.path === CONFIG_SOURCE) {
330
364
  // A property whose wrapper changed in the header is already reported
331
365
  // above; its initializer changes with it.
@@ -344,6 +378,18 @@ function detectGatingChanges (diffText, { enterpriseNames = [] } = {}) {
344
378
  return findings
345
379
  }
346
380
 
381
+ /**
382
+ * Drop findings already dispatched for this PR. `sent` is the text of the
383
+ * review state's dispatched-gating.txt: one `name change_kind` key per line.
384
+ * Filtering here, before buildDocImpact, keeps the ticket title and body to
385
+ * the findings that remain, and lets an eleventh finding in once the first
386
+ * ten are sent.
387
+ */
388
+ function withoutSent (findings, sent) {
389
+ const done = new Set(String(sent || '').split('\n').map((l) => l.trim()).filter(Boolean))
390
+ return findings.filter((f) => !done.has(`${f.name} ${f.change_kind}`))
391
+ }
392
+
347
393
  /**
348
394
  * Shape findings as the doc-impact report the review workflow dispatches
349
395
  * (the same JSON the model writes to doc-impact.json), or null when there is
@@ -385,6 +431,7 @@ module.exports = {
385
431
  CONFIG_SOURCE,
386
432
  detectGatingChanges,
387
433
  buildDocImpact,
434
+ withoutSent,
388
435
  enumChanges,
389
436
  enterprisePropertyChanges,
390
437
  enterprisePropertyNames,
@@ -132,48 +132,90 @@ function insertReleaseSection(pageContent, sectionText) {
132
132
  }
133
133
 
134
134
  /**
135
- * Reorders the entries within each `=== category` of a curated section so that
136
- * entries sharing an `Area::` label sit together. Areas are ordered
137
- * ALPHABETICALLY (case-insensitive), so a given area sits in the same place on
138
- * every release instead of moving with the source's merge order; entries keep
139
- * their source order within an area, so the sort is stable. The label is
140
- * repeated per entry (not merged into one heading), so the page format is
141
- * unchanged — only the order of entries within a category changes. Running it on
142
- * an already-clustered section is a no-op, so it is safe to apply on every
143
- * release and on a backfill.
135
+ * Groups the entries within each `=== category` of a curated section by their
136
+ * `Area::` label. Areas are ordered ALPHABETICALLY (case-insensitive), so a given
137
+ * area sits in the same place on every release instead of moving with the
138
+ * source's merge order; entries keep their source order within an area, so the
139
+ * sort is stable. Each area is written ONCE per category, as the definition-list
140
+ * term, with its entries as a bulleted list under it:
144
141
  *
145
- * Only the clean single-line `Area:: description` definition-list form is
146
- * reordered. A category whose entries are not all of that form — a phase-1
147
- * candidate's plain `*` bullets, or a multi-line entry — is left in its original
148
- * order, so this is a safe no-op on anything it does not recognize. A curated
149
- * category (one with at least one `Area::` entry) that bails logs a warning, since
150
- * it then ships unclustered. Area keys are trimmed for grouping, so `Kafka API`
151
- * and `Kafka API ` are one area. Headings and any prose outside a category are
152
- * passed through untouched.
142
+ * Cloud Topics::
143
+ * * First entry.
144
+ * * Second entry.
145
+ *
146
+ * An area with one entry still gets a one-item list. Input entries may be in
147
+ * either form: single-line `Area:: description` (what curation writes) or an
148
+ * already-grouped `Area::` paragraph with single-line `* ` items. A category can
149
+ * mix the two. Running it on its own output is a no-op, so it is safe to apply on
150
+ * every release, on a backfill, and on a section grouped by hand.
151
+ *
152
+ * A category with any other kind of entry (a phase-1 candidate's plain `*`
153
+ * bullets, or a multi-line entry) is not grouped, so this is a safe no-op on
154
+ * anything it does not recognize. Its entries keep their order and text. The
155
+ * one change is that single-line entries on adjacent lines come out with a
156
+ * blank line between them, which AsciiDoc renders the same way. Line endings
157
+ * in the output are LF. A curated category (one with at least
158
+ * one `Area::` entry) that is left as it is logs a warning, because it then
159
+ * ships ungrouped. Area keys are trimmed, so `Kafka API` and `Kafka API ` are one
160
+ * area, written as the trimmed label. Headings and any prose outside a category
161
+ * are passed through untouched.
153
162
  *
154
163
  * @param {string} sectionText - A rendered release section.
155
- * @return {string} The section with each category's entries clustered by area,
164
+ * @return {string} The section with each category's entries grouped by area,
156
165
  * ending in a single newline.
157
166
  */
158
167
  function clusterSectionByArea(sectionText) {
159
- // The area label is the text up to the first `:: ` (double-colon + space),
160
- // which is the AsciiDoc definition-list term separator. A lazy match stops at
161
- // that first separator, so a `::` later in the description is left alone. A
162
- // leading list marker (`*`, `-`, `.`), at column zero or indented, is rejected
163
- // so a plain bullet that happens to carry a `::` — a phase-1 candidate line —
164
- // is not mistaken for an area entry; its category then bails and is left
165
- // untouched.
166
- const ENTRY_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s/;
168
+ // A single-line entry: the area label is the text up to the first `:: `
169
+ // (double-colon + space), the AsciiDoc definition-list term separator. A lazy
170
+ // match stops at that first separator, so a `::` later in the description is
171
+ // left alone. A leading list marker (`*`, `-`, `.`), at column zero or
172
+ // indented, is rejected so a plain bullet that happens to carry a `::` (a
173
+ // phase-1 candidate line) is not mistaken for an area entry.
174
+ const ENTRY_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s+(\S.*)$/;
175
+ // A grouped area: a term line with nothing after the `::`.
176
+ const TERM_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s*$/;
177
+ // An item of a grouped area: one `* ` bullet on one line.
178
+ const ITEM_RE = /^\* (\S.*)$/;
179
+
180
+ // Parses one paragraph into [area, items], or null if it is not a recognized
181
+ // entry. A grouped paragraph must be a term line followed only by items.
182
+ const parseEntry = (para) => {
183
+ if (para.length === 1) {
184
+ const m = para[0].match(ENTRY_RE);
185
+ if (m) return [m[1].trim(), [m[2]]];
186
+ }
187
+ const t = para[0].match(TERM_RE);
188
+ if (t && para.length > 1) {
189
+ const items = [];
190
+ for (const line of para.slice(1)) {
191
+ const i = line.match(ITEM_RE);
192
+ if (!i) return null;
193
+ items.push(i[1]);
194
+ }
195
+ return [t[1].trim(), items];
196
+ }
197
+ return null;
198
+ };
199
+ const looksCurated = (para) => ENTRY_RE.test(para[0]) || TERM_RE.test(para[0]);
167
200
 
168
201
  // Tokenize into paragraphs: maximal runs of non-blank lines. In this format
169
- // every heading and every entry is one such paragraph.
170
- const lines = String(sectionText).replace(/\n+$/, '').split('\n');
202
+ // every heading, every single-line entry, and every grouped area is one such
203
+ // paragraph. Single-line entries on adjacent lines, with no blank line
204
+ // between them, are valid AsciiDoc too, so each one starts its own paragraph.
205
+ // A grouped area's term line does not match ENTRY_RE, so its bullets stay in
206
+ // one paragraph with it. CRLF and CR line endings become LF first: `.` in
207
+ // the patterns above does not match `\r`, so a CRLF line would match nothing.
208
+ const lines = String(sectionText).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n');
171
209
  const paragraphs = [];
172
210
  let current = [];
173
211
  for (const line of lines) {
174
212
  if (/^\s*$/.test(line)) {
175
213
  if (current.length) { paragraphs.push(current); current = []; }
176
214
  } else {
215
+ if (current.length === 1 && ENTRY_RE.test(current[0]) && ENTRY_RE.test(line)) {
216
+ paragraphs.push(current);
217
+ current = [];
218
+ }
177
219
  current.push(line);
178
220
  }
179
221
  }
@@ -181,38 +223,35 @@ function clusterSectionByArea(sectionText) {
181
223
 
182
224
  const out = [];
183
225
  let categoryEntries = null; // entry paragraphs of the open `=== category`
184
- let categoryBail = false; // the open category has an unrecognized entry
185
226
  let categoryTitle = ''; // the open category's heading text, for warnings
186
227
 
187
228
  const flushCategory = () => {
188
229
  if (!categoryEntries) return;
189
- if (categoryBail) {
190
- // A phase-1 bullet category is expected to bail. A curated one (it has at
191
- // least one `Area::` entry) bailing means some entry spans several lines,
192
- // so the whole category ships unclustered — say so rather than fail quietly.
193
- if (categoryEntries.some((entry) => ENTRY_RE.test(entry[0]))) {
194
- console.warn(`${LOG_TAG} WARN: "${categoryTitle}" not grouped by area: an entry is not a single-line \`Area:: description\` (for example a \`+\` continuation or a code block).`);
230
+ const parsed = categoryEntries.map(parseEntry);
231
+ if (parsed.some((e) => e === null)) {
232
+ // A phase-1 bullet category is expected to be left as it is. A curated one
233
+ // (it has at least one `Area::` entry) left as it is means some entry is
234
+ // neither form, so the whole category ships ungrouped. Say so rather than
235
+ // fail quietly.
236
+ if (categoryEntries.some(looksCurated)) {
237
+ console.warn(`${LOG_TAG} WARN: "${categoryTitle}" not grouped by area: an entry is neither a single-line \`Area:: description\` nor an \`Area::\` term with single-line \`* \` items (for example a \`+\` continuation or a code block).`);
195
238
  }
196
239
  for (const entry of categoryEntries) out.push(entry);
197
240
  } else {
198
241
  const order = [];
199
242
  const groups = new Map();
200
- for (const entry of categoryEntries) {
201
- // Trimmed, so a stray space before `::` does not split one area in two.
202
- // Only the grouping key is trimmed; the entry text is emitted verbatim.
203
- const key = entry[0].match(ENTRY_RE)[1].trim();
243
+ for (const [key, items] of parsed) {
204
244
  if (!groups.has(key)) { groups.set(key, []); order.push(key); }
205
- groups.get(key).push(entry);
245
+ groups.get(key).push(...items);
206
246
  }
207
247
  // Areas alphabetical (case-insensitive), so an area lands in the same
208
248
  // place on every release; entries keep their source order within an area.
209
249
  order.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }));
210
250
  for (const key of order) {
211
- for (const entry of groups.get(key)) out.push(entry);
251
+ out.push([`${key}::`, ...groups.get(key).map((item) => `* ${item}`)]);
212
252
  }
213
253
  }
214
254
  categoryEntries = null;
215
- categoryBail = false;
216
255
  };
217
256
 
218
257
  for (const para of paragraphs) {
@@ -222,7 +261,6 @@ function clusterSectionByArea(sectionText) {
222
261
  flushCategory();
223
262
  out.push(para);
224
263
  categoryEntries = [];
225
- categoryBail = false;
226
264
  categoryTitle = first.replace(/^===\s+/, '').trim();
227
265
  } else if (/^==\s/.test(first)) {
228
266
  // The release heading (or any other top-level heading) closes the open
@@ -232,7 +270,6 @@ function clusterSectionByArea(sectionText) {
232
270
  categoryEntries = null;
233
271
  } else if (categoryEntries) {
234
272
  categoryEntries.push(para);
235
- if (para.length !== 1 || !ENTRY_RE.test(para[0])) categoryBail = true;
236
273
  } else {
237
274
  // Prose outside any category (for example an intro paragraph): untouched.
238
275
  out.push(para);
@@ -250,10 +287,11 @@ function clusterSectionByArea(sectionText) {
250
287
  * @param {string} options.body - The raw rpchangelog release body markdown.
251
288
  * @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
252
289
  * @param {string} options.date - The release date as `YYYY-MM-DD`.
290
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
253
291
  * @return {string} The rendered AsciiDoc section.
254
292
  */
255
- function buildReleaseSection({ body, version, date }) {
256
- const { sections } = parseReleaseBody(body);
293
+ function buildReleaseSection({ body, version, date, propertyNames }) {
294
+ const { sections } = parseReleaseBody(body, { propertyNames });
257
295
  return renderReleaseSection({ version, date, sections });
258
296
  }
259
297
 
@@ -306,9 +344,10 @@ function assertSectionMatchesTag(section, version) {
306
344
  * @param {string} options.tag - The release tag (`v26.2.3`).
307
345
  * @param {string} [options.date] - The authored release date as `YYYY-MM-DD` (phase 1 only).
308
346
  * @param {string} options.pageContent - The current target page content.
347
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros (phase 1 only).
309
348
  * @return {{status: 'ok'|'skipped', reason?: string, section?: string, content?: string}}
310
349
  */
311
- function generateReleaseNotes({ body, section, tag, date, pageContent }) {
350
+ function generateReleaseNotes({ body, section, tag, date, pageContent, propertyNames }) {
312
351
  if (body == null && section == null) {
313
352
  throw new Error('Provide either body (phase 1) or section (phase 2).');
314
353
  }
@@ -337,7 +376,7 @@ function generateReleaseNotes({ body, section, tag, date, pageContent }) {
337
376
  // have to, and so a hand-written section is normalized the same way.
338
377
  finalSection = clusterSectionByArea(section);
339
378
  } else {
340
- finalSection = buildReleaseSection({ body, version, date });
379
+ finalSection = buildReleaseSection({ body, version, date, propertyNames });
341
380
  }
342
381
  const content = insertReleaseSection(pageContent, finalSection);
343
382
  return { status: 'ok', section: finalSection, content };
@@ -8,7 +8,8 @@
8
8
  //
9
9
  // This module does ONLY the deterministic, no-judgment work: split into
10
10
  // sections, join wrapped bullet lines, strip source noise, unescape backticks,
11
- // de-duplicate backport repeats, and normalize category order/case. It does NOT
11
+ // link known configuration properties, de-duplicate backport repeats, and
12
+ // normalize category order/case. It does NOT
12
13
  // rewrite voice, add `Area::` labels, normalize units, phrase CVEs, or drop
13
14
  // bodyless entries — those need editorial judgment and belong to the curation
14
15
  // step that runs on the generated page.
@@ -88,18 +89,43 @@ function stripLeadingIssueRef(text) {
88
89
  * the editorial curation step.
89
90
  *
90
91
  * @param {string} raw - The joined raw bullet text.
92
+ * @param {Object} [options]
93
+ * @param {Set<string>} [options.propertyNames] - Known property names to link (see linkProperties).
91
94
  * @return {string} The cleaned entry text.
92
95
  */
93
- function cleanEntry(raw) {
96
+ function cleanEntry(raw, { propertyNames } = {}) {
94
97
  let text = raw.replace(/\s+/g, ' ').trim();
95
98
  text = stripLeadingIssueRef(text);
96
99
  text = stripAttribution(text);
97
100
  text = text.replace(/\\`/g, '`');
101
+ text = linkProperties(text, propertyNames);
98
102
  text = normalizeUnits(text);
99
103
  text = escapeAsciiDocBraces(text);
100
104
  return text.trim();
101
105
  }
102
106
 
107
+ /**
108
+ * Rewrites a backticked configuration property name as a `prop:` macro with
109
+ * `link=true`, so the page links it to the property reference. Without
110
+ * `link=true`, the macro shows only a hover tooltip. Engineers cannot write `prop:` in
111
+ * the source, because nothing resolves it outside the docs build, so this is
112
+ * where the link is added.
113
+ *
114
+ * Only an exact match for a known name is rewritten: `` `retention.ms` `` is,
115
+ * `` `retention.ms=1000` `` and `` `health_manager` `` are not. A name with no
116
+ * `_` or `.` (such as `admin` or `rack`) is never rewritten, because in prose
117
+ * those words usually mean something other than the property.
118
+ *
119
+ * @param {string} text - The entry text, with backticks already unescaped.
120
+ * @param {Set<string>} [propertyNames] - Known property names. Without it, the text is returned unchanged.
121
+ * @return {string} The text with known property names as `prop:<name>[link=true]`.
122
+ */
123
+ function linkProperties(text, propertyNames) {
124
+ if (!propertyNames || propertyNames.size === 0) return text;
125
+ return text.replace(/`([\w.-]+)`/g, (match, name) =>
126
+ /[_.]/.test(name) && propertyNames.has(name) ? `prop:${name}[link=true]` : match);
127
+ }
128
+
103
129
  /**
104
130
  * Rewrites the source's underscore data-size notation to the readable unit the
105
131
  * standard requires (`4_MiB` -> `4 MiB`). Only a digit followed by `_` and a
@@ -131,11 +157,13 @@ function escapeAsciiDocBraces(text) {
131
157
  * grouped by canonical category in page order.
132
158
  *
133
159
  * @param {string} body - The raw GitHub Release body markdown.
160
+ * @param {Object} [options]
161
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
134
162
  * @return {{sections: Array<{kind: string, title: string, entries: string[]}>}}
135
163
  * Sections in page order (Features, Bug fixes, Improvements); a category with
136
164
  * no entries is omitted.
137
165
  */
138
- function parseReleaseBody(body) {
166
+ function parseReleaseBody(body, { propertyNames } = {}) {
139
167
  if (typeof body !== 'string' || body.trim() === '') {
140
168
  throw new Error('Release body is empty.');
141
169
  }
@@ -155,7 +183,7 @@ function parseReleaseBody(body) {
155
183
 
156
184
  const flush = () => {
157
185
  if (current !== null && currentKind && buckets[currentKind]) {
158
- const entry = cleanEntry(current);
186
+ const entry = cleanEntry(current, { propertyNames });
159
187
  if (entry) buckets[currentKind].push(entry);
160
188
  }
161
189
  current = null;
@@ -231,6 +259,7 @@ module.exports = {
231
259
  stripAttribution,
232
260
  stripLeadingIssueRef,
233
261
  cleanEntry,
262
+ linkProperties,
234
263
  normalizeUnits,
235
264
  escapeAsciiDocBraces,
236
265
  PAGE_ORDER,
@@ -0,0 +1,65 @@
1
+ // Load the configuration property names that the release-notes generator links
2
+ // as `prop:` macros. The names come from the same redpanda-properties-<tag>.json
3
+ // attachment that the prop macro validates against, so a name linked here is a
4
+ // name the docs build can resolve.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ // The attachment directory, relative to the docs repository root.
10
+ const ATTACHMENTS_DIR = 'modules/reference/attachments';
11
+ const PROPERTIES_JSON_RX = /^redpanda-properties-v(\d+)\.(\d+)\.(\d+)\.json$/;
12
+
13
+ /**
14
+ * Returns the property names in a parsed property JSON file.
15
+ *
16
+ * @param {Object} json - Parsed redpanda-properties-<tag>.json content.
17
+ * @return {Set<string>} The property names.
18
+ * @throws {Error} If the file has no `properties` object.
19
+ */
20
+ function propertyNamesFromJson(json) {
21
+ if (!json || typeof json.properties !== 'object' || json.properties === null) {
22
+ throw new Error('Property file has no "properties" object.');
23
+ }
24
+ return new Set(Object.keys(json.properties));
25
+ }
26
+
27
+ /**
28
+ * Finds the newest GA property file for a tag's release series. A 26.2 tag
29
+ * uses 26.2 data only, the same rule the prop macro applies to a page, so a
30
+ * property from another release series is never linked.
31
+ *
32
+ * @param {string} dir - The directory to search.
33
+ * @param {string} tag - The release tag (`v26.2.5` or `26.2.5`).
34
+ * @return {string|null} The path to the newest matching file, or null if there is none.
35
+ */
36
+ function findPropertiesFile(dir, tag) {
37
+ const m = String(tag).match(/^v?(\d+)\.(\d+)\./);
38
+ if (!m || !fs.existsSync(dir)) return null;
39
+ const [, major, minor] = m;
40
+ let best = null;
41
+ for (const file of fs.readdirSync(dir)) {
42
+ const f = file.match(PROPERTIES_JSON_RX);
43
+ if (!f || f[1] !== major || f[2] !== minor) continue;
44
+ const patch = Number(f[3]);
45
+ if (!best || patch > best.patch) best = { patch, file };
46
+ }
47
+ return best ? path.join(dir, best.file) : null;
48
+ }
49
+
50
+ /**
51
+ * Reads the property names from a property JSON file.
52
+ *
53
+ * @param {string} file - Path to a redpanda-properties-<tag>.json file.
54
+ * @return {Set<string>} The property names.
55
+ */
56
+ function loadPropertyNames(file) {
57
+ return propertyNamesFromJson(JSON.parse(fs.readFileSync(file, 'utf8')));
58
+ }
59
+
60
+ module.exports = {
61
+ ATTACHMENTS_DIR,
62
+ propertyNamesFromJson,
63
+ findPropertiesFile,
64
+ loadPropertyNames,
65
+ };