@redpanda-data/docs-extensions-and-macros 5.56.1 → 5.58.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.
@@ -1223,7 +1223,7 @@ npm test -- --coverage
1223
1223
  ==== The live overrides corpus
1224
1224
 
1225
1225
  `__tests__/tools/property-extractor/property-corpus-rendering.test.js` runs the real
1226
- overrides file, not a fixture, because a fixture passes while the live 438-entry corpus
1226
+ overrides file, not a fixture, because a fixture passes while the live overrides corpus
1227
1227
  breaks. Two files feed it, both mirrors of the `docs` repo:
1228
1228
 
1229
1229
  `__tests__/docs-data/property-overrides.json`:: A byte copy of `docs-data/property-overrides.json`.
@@ -1245,7 +1245,9 @@ test, so it sat three months and 114 entries behind. That test now has its own
1245
1245
  `property-overrides-admin-fixture.json`; put new fixture data there or in the test
1246
1246
  itself, never in the mirror.
1247
1247
 
1248
- Refresh both from a `docs` checkout:
1248
+ Every Monday, `.github/workflows/property-corpus-drift.yml` compares both files against `docs` main. When they have drifted, it runs the recipe below and opens or updates one pull request from `bot/refresh-property-corpus`. If that PR's tests fail, the refresh has exposed a real bug: push the fix to the same branch, and while the PR is open the job stops updating the branch once its latest commit isn't the bot's.
1249
+
1250
+ To refresh by hand, for example between weekly runs, run the same recipe from a `docs` checkout:
1249
1251
 
1250
1252
  [,bash]
1251
1253
  ----
@@ -169,7 +169,8 @@ For each version pair `(oldVersion → newVersion)`:
169
169
  "removedFields": 2, // Breaking changes!
170
170
  "deprecatedComponents": 0,
171
171
  "deprecatedFields": 1,
172
- "changedDefaults": 0
172
+ "changedDefaults": 0, // A default changed or was removed (breaking)
173
+ "documentedDefaults": 0 // A field with no default now states one (informational)
173
174
  },
174
175
  "details": {
175
176
  "newComponents": [
@@ -385,7 +386,8 @@ Changes between two consecutive versions:
385
386
  "newFields": [...],
386
387
  "removedFields": [...],
387
388
  "deprecatedFields": [...],
388
- "changedDefaults": [...]
389
+ "changedDefaults": [...],
390
+ "documentedDefaults": [...]
389
391
  },
390
392
  "binaryAnalysis": {
391
393
  "versions": {
@@ -63,7 +63,8 @@ function createMasterDiff(intermediateResults, finalDiffPath, outputPath) {
63
63
  removedFields: releases.reduce((sum, r) => sum + (r.summary.removedFields || 0), 0),
64
64
  deprecatedComponents: releases.reduce((sum, r) => sum + (r.summary.deprecatedComponents || 0), 0),
65
65
  deprecatedFields: releases.reduce((sum, r) => sum + (r.summary.deprecatedFields || 0), 0),
66
- changedDefaults: releases.reduce((sum, r) => sum + (r.summary.changedDefaults || 0), 0)
66
+ changedDefaults: releases.reduce((sum, r) => sum + (r.summary.changedDefaults || 0), 0),
67
+ documentedDefaults: releases.reduce((sum, r) => sum + (r.summary.documentedDefaults || 0), 0)
67
68
  };
68
69
 
69
70
  const masterDiff = {
@@ -3,6 +3,16 @@
3
3
  * Outputs a console-parseable format for GitHub Actions
4
4
  */
5
5
 
6
+ /**
7
+ * Format a field default for the Markdown PR summary. A field with no default
8
+ * is `undefined`; a declared null default (Default(nil)) is a value.
9
+ * @param {*} value - The default, or undefined when the field has none
10
+ * @returns {string} Inline code, or `_no default_`
11
+ */
12
+ function formatDefault(value) {
13
+ return value === undefined ? '_no default_' : `\`${JSON.stringify(value)}\``;
14
+ }
15
+
6
16
  /**
7
17
  * Render Bloblang changes section for PR summary
8
18
  * @param {object} data - Bloblang change data
@@ -238,6 +248,9 @@ function generateMultiVersionPRSummary(masterDiff, binaryAnalysis = null, drafte
238
248
  if (total.changedDefaults > 0) {
239
249
  lines.push(`- **${total.changedDefaults}** changed default values`);
240
250
  }
251
+ if (total.documentedDefaults > 0) {
252
+ lines.push(`- **${total.documentedDefaults}** newly stated default values`);
253
+ }
241
254
  if (total.newBloblangMethods > 0) {
242
255
  lines.push(`- **${total.newBloblangMethods}** new Bloblang methods`);
243
256
  }
@@ -375,12 +388,25 @@ function generateMultiVersionPRSummary(masterDiff, binaryAnalysis = null, drafte
375
388
  lines.push('| Component | Field | Old Default | New Default |');
376
389
  lines.push('|-----------|-------|-------------|-------------|');
377
390
  changedDefaults.forEach(change => {
378
- const oldVal = change.oldDefault !== undefined ? `\`${JSON.stringify(change.oldDefault)}\`` : '_none_';
379
- const newVal = change.newDefault !== undefined ? `\`${JSON.stringify(change.newDefault)}\`` : '_none_';
391
+ const oldVal = formatDefault(change.oldDefault);
392
+ const newVal = formatDefault(change.newDefault);
380
393
  lines.push(`| \`${change.component}\` | \`${change.field}\` | ${oldVal} | ${newVal} |`);
381
394
  });
382
395
  lines.push('');
383
396
  }
397
+
398
+ // Fields that gained a stated default
399
+ const documentedDefaults = details.documentedDefaults || [];
400
+ if (documentedDefaults.length > 0) {
401
+ lines.push(`**Newly Stated Defaults (${documentedDefaults.length}):**`);
402
+ lines.push('');
403
+ lines.push("_These fields now state a default. They usually behaved this way already, so they are not listed as changes in What's new. Check that none of them changed behavior._");
404
+ lines.push('');
405
+ documentedDefaults.forEach(change => {
406
+ lines.push(`- \`${change.component}.${change.field}\`: ${formatDefault(change.newDefault)}`);
407
+ });
408
+ lines.push('');
409
+ }
384
410
  }
385
411
 
386
412
  // Writer action items (aggregate)
@@ -558,9 +584,9 @@ function generateMultiVersionPRSummary(masterDiff, binaryAnalysis = null, drafte
558
584
  lines.push('**Review Changed Defaults for Breaking Changes:**');
559
585
  lines.push('');
560
586
  allChangedDefaults.forEach(change => {
561
- const oldVal = change.oldDefault !== undefined ? JSON.stringify(change.oldDefault) : 'none';
562
- const newVal = change.newDefault !== undefined ? JSON.stringify(change.newDefault) : 'none';
563
- lines.push(`- [ ] \`${change.component}.${change.field}\`: \`${oldVal}\` → \`${newVal}\` — changed in **${change.version}**`);
587
+ const oldVal = formatDefault(change.oldDefault);
588
+ const newVal = formatDefault(change.newDefault);
589
+ lines.push(`- [ ] \`${change.component}.${change.field}\`: ${oldVal} → ${newVal} — changed in **${change.version}**`);
564
590
  });
565
591
  lines.push('');
566
592
  }
@@ -717,6 +743,10 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
717
743
  lines.push(`- **${stats.changedDefaults}** default value change${stats.changedDefaults !== 1 ? 's' : ''}`);
718
744
  }
719
745
 
746
+ if (stats.documentedDefaults > 0) {
747
+ lines.push(`- **${stats.documentedDefaults}** newly stated default value${stats.documentedDefaults !== 1 ? 's' : ''} (no behavior change expected)`);
748
+ }
749
+
720
750
  lines.push('');
721
751
 
722
752
  // Writer Reminder for Commercial Names
@@ -1233,14 +1263,25 @@ function generatePRSummary(diffData, binaryAnalysis = null, draftedConnectors =
1233
1263
  const [type, name] = component.split(':');
1234
1264
  lines.push(`**${type}/${name}:**`);
1235
1265
  changes.forEach(c => {
1236
- const oldStr = JSON.stringify(c.oldDefault);
1237
- const newStr = JSON.stringify(c.newDefault);
1238
- lines.push(`- \`${c.field}\`: ${oldStr} → ${newStr}`);
1266
+ lines.push(`- \`${c.field}\`: ${formatDefault(c.oldDefault)} → ${formatDefault(c.newDefault)}`);
1239
1267
  });
1240
1268
  lines.push('');
1241
1269
  });
1242
1270
  }
1243
1271
 
1272
+ // Newly stated defaults
1273
+ const documentedDefaults = diffData.details.documentedDefaults || [];
1274
+ if (documentedDefaults.length > 0) {
1275
+ lines.push('#### Newly Stated Defaults');
1276
+ lines.push('');
1277
+ lines.push("_These fields now state a default. They usually behaved this way already, so they are not listed as changes in What's new. Check that none of them changed behavior._");
1278
+ lines.push('');
1279
+ documentedDefaults.forEach(c => {
1280
+ lines.push(`- \`${c.component}.${c.field}\`: ${formatDefault(c.newDefault)}`);
1281
+ });
1282
+ lines.push('');
1283
+ }
1284
+
1244
1285
  // New Bloblang Methods
1245
1286
  if (stats.newBloblangMethods > 0 && diffData.details.newBloblangMethods) {
1246
1287
  lines.push('#### New Bloblang Methods');
@@ -175,6 +175,10 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
175
175
  const deprecatedFields = [];
176
176
  // Changed default values
177
177
  const changedDefaults = [];
178
+ // Fields that had no default and now state one. The field behaved the same
179
+ // way before, the spec just didn't say so, so these are not default changes
180
+ // and stay out of What's new and the breaking-change warning.
181
+ const documentedDefaults = [];
178
182
 
179
183
  Object.keys(newMap).forEach(cKey => {
180
184
  if (!(cKey in oldMap)) return;
@@ -209,14 +213,16 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
209
213
  const newDefaultStr = JSON.stringify(newDefault);
210
214
 
211
215
  if (oldDefaultStr !== newDefaultStr) {
212
- changedDefaults.push({
216
+ const entry = {
213
217
  component: cKey,
214
218
  field: fName,
215
219
  oldDefault: oldDefault,
216
220
  newDefault: newDefault,
217
221
  description: newFieldObj && newFieldObj.description,
218
222
  shortDescription: newFieldObj && newFieldObj.short_description
219
- });
223
+ };
224
+ if (oldDefault === undefined) documentedDefaults.push(entry);
225
+ else changedDefaults.push(entry);
220
226
  }
221
227
  }
222
228
  });
@@ -280,6 +286,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
280
286
  deprecatedComponents: deprecatedComponents.length,
281
287
  deprecatedFields: deprecatedFields.length,
282
288
  changedDefaults: changedDefaults.length,
289
+ documentedDefaults: documentedDefaults.length,
283
290
  platformTransitions: platformTransitions.length,
284
291
  newBloblangMethods: newBloblangMethods.length,
285
292
  removedBloblangMethods: removedBloblangMethods.length,
@@ -296,6 +303,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
296
303
  deprecatedComponents,
297
304
  deprecatedFields,
298
305
  changedDefaults,
306
+ documentedDefaults,
299
307
  platformTransitions,
300
308
  newBloblangMethods,
301
309
  removedBloblangMethods,
@@ -646,7 +654,8 @@ function printDeltaReport(oldIndex, newIndex, opts = {}) {
646
654
  const oldDefaultStr = JSON.stringify(oldDefault);
647
655
  const newDefaultStr = JSON.stringify(newDefault);
648
656
 
649
- if (oldDefaultStr !== newDefaultStr) {
657
+ // A field that had no default and now states one is not a change.
658
+ if (oldDefaultStr !== newDefaultStr && oldDefault !== undefined) {
650
659
  changedDefaultsList.push({
651
660
  component: cKey,
652
661
  field: fName,
@@ -719,8 +728,8 @@ function printDeltaReport(oldIndex, newIndex, opts = {}) {
719
728
  console.log('➤ Changed default values:');
720
729
  changedDefaultsList.forEach(entry => {
721
730
  const { component, field, oldDefault, newDefault } = entry;
722
- const oldStr = JSON.stringify(oldDefault);
723
- const newStr = JSON.stringify(newDefault);
731
+ const oldStr = oldDefault === undefined ? 'no default' : JSON.stringify(oldDefault);
732
+ const newStr = newDefault === undefined ? 'no default' : JSON.stringify(newDefault);
724
733
  console.log(` • ${component} → ${field}`);
725
734
  console.log(` Old: ${oldStr}`);
726
735
  console.log(` New: ${newStr}`);
@@ -657,7 +657,8 @@ function buildChangedDefaultsTable (changedDefaults, capFn) {
657
657
  const byFieldAndDefaults = {}
658
658
  for (const change of changedDefaults) {
659
659
  const [type, compName] = change.component.split(':')
660
- const compositeKey = `${change.field}|${String(change.oldDefault)}|${String(change.newDefault)}`
660
+ // JSON keeps null apart from "null" and one object default apart from another.
661
+ const compositeKey = JSON.stringify([change.field, change.oldDefault, change.newDefault])
661
662
  if (!byFieldAndDefaults[compositeKey]) {
662
663
  byFieldAndDefaults[compositeKey] = {
663
664
  field: change.field,
@@ -675,23 +676,31 @@ function buildChangedDefaultsTable (changedDefaults, capFn) {
675
676
  section += '|Field |Old default |New default |Description |Affected components\n\n'
676
677
 
677
678
  for (const [, info] of Object.entries(byFieldAndDefaults)) {
678
- // Values render as literal inline code, so interpolations like
679
- // ${!uuid_v4()} and characters like * or _ show as typed.
680
679
  // Values render as literal inline code through pass:c, which, unlike
681
680
  // `+...+`, also keeps values with ++ or with spaces at either end. Strings
682
681
  // that are empty, start or end with whitespace, or contain a newline show
683
682
  // as JSON, so " " and "\n" read as the values they are.
684
- const formatDefault = (val) => {
685
- if (val === undefined || val === null) return 'none'
683
+ const formatDefault = (val, asJson = false) => {
684
+ // 'none' would read as a value, and is one for some fields (compression).
685
+ if (val === undefined) return '_no default_'
686
686
  let text
687
- if (typeof val === 'string') text = (val === '' || /^\s|\s$|\n/.test(val)) ? JSON.stringify(val) : val
687
+ if (asJson) text = JSON.stringify(val)
688
+ // A null default is declared (Default(nil)), so it renders as a value.
689
+ else if (val === null) text = 'null'
690
+ else if (typeof val === 'string') text = (val === '' || /^\s|\s$|\n/.test(val)) ? JSON.stringify(val) : val
688
691
  else if (typeof val === 'number' || typeof val === 'boolean') text = String(val)
689
692
  else text = JSON.stringify(val)
690
693
  return `\`pass:c[${text.replace(/\]/g, '\\]').replace(/\|/g, '\\|')}]\``
691
694
  }
692
695
 
693
- const oldVal = formatDefault(info.oldDefault)
694
- const newVal = formatDefault(info.newDefault)
696
+ let oldVal = formatDefault(info.oldDefault)
697
+ let newVal = formatDefault(info.newDefault)
698
+ // A type change such as "5" to 5 or "null" to null would read as no
699
+ // change, so show both sides as JSON.
700
+ if (oldVal === newVal) {
701
+ oldVal = formatDefault(info.oldDefault, true)
702
+ newVal = formatDefault(info.newDefault, true)
703
+ }
695
704
  // A | in the description would end the cell and shift the row.
696
705
  const desc = info.description ? capFn(info.description).replace(/\|/g, '\\|') : '// TODO: Add description'
697
706
 
@@ -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
+ };