@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.
- package/bin/doc-tools.js +95 -1
- package/extension-utils/property-values.js +255 -0
- package/macros/PROPERTY_AND_ENTERPRISE_REFERENCES.adoc +25 -1
- package/macros/README.adoc +3 -1
- package/macros/REFERENCE.adoc +158 -1
- package/macros/USER_GUIDE.adoc +28 -0
- package/macros/prop.js +135 -0
- package/package.json +1 -1
- package/tools/lint-strings/surfaces/connect.js +18 -1
- package/tools/migrate-property-refs.js +15 -12
- package/tools/migrate-property-values.js +1255 -0
- package/tools/property-extractor/README.adoc +4 -2
- package/tools/redpanda-connect/AUTOMATION.md +4 -2
- package/tools/redpanda-connect/multi-version-summary.js +2 -1
- package/tools/redpanda-connect/pr-summary-formatter.js +49 -8
- package/tools/redpanda-connect/report-delta.js +14 -5
- package/tools/redpanda-connect/rpcn-connector-docs-handler.js +17 -8
- package/tools/redpanda-release-notes/generate-release-notes.js +87 -48
- package/tools/redpanda-release-notes/parse-release-body.js +33 -4
- package/tools/redpanda-release-notes/property-names.js +65 -0
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
379
|
-
const newVal =
|
|
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 =
|
|
562
|
-
const newVal =
|
|
563
|
-
lines.push(`- [ ] \`${change.component}.${change.field}\`:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
|
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
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// that first separator, so a `::` later in the description is
|
|
162
|
-
// leading list marker (`*`, `-`, `.`), at column zero or
|
|
163
|
-
// so a plain bullet that happens to carry a `::`
|
|
164
|
-
// is not mistaken for an area entry
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|