@redpanda-data/docs-extensions-and-macros 5.51.2 → 5.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bin/doc-tools-mcp.js +4 -0
  2. package/bin/doc-tools.js +119 -0
  3. package/bin/mcp-tools/rpcn-docs.js +1 -0
  4. package/extensions/REFERENCE.adoc +19 -4
  5. package/extensions/generate-rp-connect-categories.js +76 -25
  6. package/extensions/generate-rp-connect-info.js +150 -40
  7. package/extensions/modify-connect-tag-playbook.js +183 -17
  8. package/extensions/util/connect-catalog.js +153 -0
  9. package/macros/rp-connect-components.js +23 -8
  10. package/package.json +1 -1
  11. package/tools/lint-strings/diff.js +4 -1
  12. package/tools/lint-strings/engine.js +5 -0
  13. package/tools/lint-strings/fixtures/connect/lint_connect.go +1 -1
  14. package/tools/lint-strings/fixtures/connect/repo/go.mod.txt +5 -0
  15. package/tools/lint-strings/fixtures/connect/repo/internal/cli/lint.go +19 -0
  16. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/common.go +20 -0
  17. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/docs.go +8 -0
  18. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/input_thing.go +32 -0
  19. package/tools/lint-strings/fixtures/connect/repo/internal/impl/azure/output_thing.go +12 -0
  20. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/bloblang.go +21 -0
  21. package/tools/lint-strings/fixtures/connect/repo/internal/impl/widget/scanner.go +12 -0
  22. package/tools/lint-strings/fixtures/connect/repo/internal/plugins/info.csv +3 -0
  23. package/tools/lint-strings/fixtures/connect/repo/internal/retries/retries.go +13 -0
  24. package/tools/lint-strings/go-index.js +1143 -0
  25. package/tools/lint-strings/index.js +28 -2
  26. package/tools/lint-strings/rules/common.js +10 -2
  27. package/tools/lint-strings/surfaces/connect.js +1484 -137
  28. package/tools/lint-strings/surfaces/rpk.js +1 -0
  29. package/tools/redpanda-connect/generate-rpcn-connector-docs.js +23 -13
  30. package/tools/redpanda-connect/report-delta.js +1 -0
  31. package/tools/redpanda-connect/rpcn-connector-docs-handler.js +19 -13
  32. package/tools/redpanda-connect/templates/connector.hbs +20 -2
  33. package/tools/redpanda-release-notes/generate-release-notes.js +242 -0
  34. package/tools/redpanda-release-notes/parse-release-body.js +241 -0
  35. package/tools/redpanda-release-notes/render-release-notes.js +70 -0
  36. package/tools/rpk-docs/helpers/index.js +32 -35
@@ -389,6 +389,7 @@ module.exports = {
389
389
  extract,
390
390
  scanFile,
391
391
  identity,
392
+ parseLiteralFields,
392
393
  rules: RULES,
393
394
  // Shorts and flag usages are one-liners by convention; the generic
394
395
  // too-short prose rule would flag nearly every conforming declaration.
@@ -334,6 +334,11 @@ async function generateRpcnConnectorDocs(options) {
334
334
  templateDescription,
335
335
  templateBloblang,
336
336
  writeFullDrafts,
337
+ // False when the connect repo provides the generated partials, config
338
+ // snippets, and Bloblang reference (see the modify-connect-tag-playbook
339
+ // extension). Drafts are still written, and still include the description
340
+ // partial the connect repo publishes for them.
341
+ writePartials = true,
337
342
  // Explicit opt-in for the destructive description-partial orphan sweep.
338
343
  // Off by default: see ORPHAN_SWEEP_MAX_FRACTION.
339
344
  pruneOrphanedDescriptions = false,
@@ -458,7 +463,7 @@ async function generateRpcnConnectorDocs(options) {
458
463
  : path.join(outputRoot, 'components');
459
464
  const configExamplesRoot = path.resolve(process.cwd(), 'modules/components/examples');
460
465
 
461
- if (!writeFullDrafts) {
466
+ if (!writeFullDrafts && writePartials) {
462
467
  fs.mkdirSync(fieldsOutRoot, { recursive: true });
463
468
  fs.mkdirSync(examplesOutRoot, { recursive: true });
464
469
  fs.mkdirSync(metadataOutRoot, { recursive: true });
@@ -512,7 +517,7 @@ async function generateRpcnConnectorDocs(options) {
512
517
  examplesOut = renderExamplesPartial(item);
513
518
  }
514
519
 
515
- if (fieldsOut.trim()) {
520
+ if (writePartials && fieldsOut.trim()) {
516
521
  const fPath = path.join(fieldsOutRoot, type, `${name}.adoc`);
517
522
  fs.mkdirSync(path.dirname(fPath), { recursive: true });
518
523
  if (fs.existsSync(fPath)) {
@@ -532,7 +537,7 @@ async function generateRpcnConnectorDocs(options) {
532
537
  // gate, one list. The old denylist named only the two bloblang keys, so
533
538
  // any other non-page data key (config today, anything added upstream
534
539
  // tomorrow) leaked partials no page can include.
535
- if (examplesOut.trim() && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
540
+ if (writePartials && examplesOut.trim() && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
536
541
  const ePath = path.join(examplesOutRoot, type, `${name}.adoc`);
537
542
  fs.mkdirSync(path.dirname(ePath), { recursive: true });
538
543
  fs.writeFileSync(ePath, examplesOut);
@@ -542,7 +547,7 @@ async function generateRpcnConnectorDocs(options) {
542
547
  }
543
548
  }
544
549
 
545
- if (CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
550
+ if (writePartials && CONNECTOR_PAGE_TYPE_DIRS.has(partialTypeDir)) {
546
551
  // Render the metadata partial from the connector's description. Only
547
552
  // written when the description contains a `== Metadata` section.
548
553
  // Rendered inside the guard, not before it: the loop covers every
@@ -611,8 +616,8 @@ async function generateRpcnConnectorDocs(options) {
611
616
  visitedDescriptionPartials.add(`${partialTypeDir}/${name}`);
612
617
  const dPath = path.join(descriptionOutRoot, partialTypeDir, `${name}.adoc`);
613
618
  if (descriptionOut.trim()) {
614
- fs.mkdirSync(path.dirname(dPath), { recursive: true });
615
- if (fs.existsSync(dPath)) {
619
+ if (writePartials) fs.mkdirSync(path.dirname(dPath), { recursive: true });
620
+ if (writePartials && fs.existsSync(dPath)) {
616
621
  // Same content-loss check the metadata partial ten lines up has
617
622
  // had since #236, for the same reason: regeneration is
618
623
  // authoritative, but a section disappearing from a published
@@ -637,7 +642,7 @@ async function generateRpcnConnectorDocs(options) {
637
642
  );
638
643
  }
639
644
  }
640
- fs.writeFileSync(dPath, descriptionOut);
645
+ if (writePartials) fs.writeFileSync(dPath, descriptionOut);
641
646
  // The partial now exists for this connector, so a drafted page can
642
647
  // include its body instead of freezing the summary and description
643
648
  // into the page. Gated on the file actually being written:
@@ -652,7 +657,7 @@ async function generateRpcnConnectorDocs(options) {
652
657
  if (typeof item.summary === 'string' && item.summary.trim()) {
653
658
  item.hasDescriptionMeta = true;
654
659
  }
655
- if (!writeFullDrafts) {
660
+ if (!writeFullDrafts && writePartials) {
656
661
  partialsWritten++;
657
662
  partialFiles.push(path.relative(process.cwd(), dPath));
658
663
  }
@@ -683,7 +688,7 @@ async function generateRpcnConnectorDocs(options) {
683
688
  descriptionReports.push({ connector: `${partialTypeDir}/${name}`, message: seqMsg });
684
689
  console.warn(seqMsg);
685
690
  }
686
- } else if (fs.existsSync(dPath)) {
691
+ } else if (writePartials && fs.existsSync(dPath)) {
687
692
  // The upstream description disappeared, but a previously generated
688
693
  // partial is still on disk and an already published page may include
689
694
  // it. Deleting the file would break that include, so blank it to a
@@ -732,6 +737,11 @@ async function generateRpcnConnectorDocs(options) {
732
737
 
733
738
  // typeDir is already computed at the beginning of the loop
734
739
 
740
+ // When the connect repo publishes the partials, the draft includes
741
+ // its availability, footnotes, and catalog data instead of freezing
742
+ // categories and the cgo requirement into the page.
743
+ item.connectPartials = !writePartials;
744
+
735
745
  let content;
736
746
  try {
737
747
  content = compiledTemplate(item);
@@ -767,7 +777,7 @@ async function generateRpcnConnectorDocs(options) {
767
777
  }
768
778
 
769
779
  // Bloblang function/method partials (only if includeBloblang is true)
770
- if (options.includeBloblang) {
780
+ if (writePartials && options.includeBloblang) {
771
781
  for (const { key, folder } of bloblangTypes) {
772
782
  const items = dataObj[key];
773
783
  if (!Array.isArray(items)) continue;
@@ -877,7 +887,7 @@ async function generateRpcnConnectorDocs(options) {
877
887
  // Common/Advanced config snippet YAMLs in modules/components/examples
878
888
  const commonConfig = helpers.commonConfig;
879
889
  const advancedConfig = helpers.advancedConfig;
880
- for (const [type, items] of Object.entries(dataObj)) {
890
+ for (const [type, items] of writePartials ? Object.entries(dataObj) : []) {
881
891
  if (!Array.isArray(items)) continue;
882
892
  for (const item of items) {
883
893
  if (!item.name || !item.config || !Array.isArray(item.config.children)) continue;
@@ -908,7 +918,7 @@ async function generateRpcnConnectorDocs(options) {
908
918
  // The sweep is destructive against published content, so collect the
909
919
  // candidates first and check the blast radius before writing anything.
910
920
  let orphanSweepSkipped = null;
911
- if (!writeFullDrafts && fs.existsSync(descriptionOutRoot)) {
921
+ if (!writeFullDrafts && writePartials && fs.existsSync(descriptionOutRoot)) {
912
922
  const orphans = [];
913
923
  let partialsOnDisk = 0;
914
924
  for (const typeDirName of fs.readdirSync(descriptionOutRoot)) {
@@ -966,7 +976,7 @@ async function generateRpcnConnectorDocs(options) {
966
976
  // Self-heal published page headers: pages are one-time drafts, so pages
967
977
  // created before the template emitted :description: never got one.
968
978
  let descriptionBackfill = { backfilled: [], skippedNoSummary: [] };
969
- if (!writeFullDrafts) {
979
+ if (!writeFullDrafts && writePartials) {
970
980
  descriptionBackfill = backfillPageDescriptions(dataObj);
971
981
  }
972
982
 
@@ -24,6 +24,7 @@ function generateConnectorDiffJson(oldIndex, newIndex, opts = {}) {
24
24
  type,
25
25
  status: raw.status || '',
26
26
  version: raw.version || raw.introducedInVersion || '',
27
+ summary: raw.summary || '',
27
28
  description: raw.description || '',
28
29
  requiresCgo: metadata.requiresCgo || false,
29
30
  cloudOnly: metadata.cloudOnly || false,
@@ -399,7 +399,7 @@ function updateWhatsNew ({ dataDir, oldVersion, newVersion, binaryAnalysis }) {
399
399
  for (const comp of regularComponents) {
400
400
  const typeLabel = comp.type.charAt(0).toUpperCase() + comp.type.slice(1)
401
401
  const statusLabel = comp.status || '-'
402
- let desc = comp.summary || (comp.description ? capToTwoSentences(comp.description) : '// TODO: Add description')
402
+ let desc = comp.summary ? capToTwoSentences(comp.summary) : (comp.description ? capToTwoSentences(comp.description) : '// TODO: Add description')
403
403
 
404
404
  if (comp.requiresCgo) {
405
405
  const cgoNote = '\nNOTE: Requires a cgo-enabled binary. See the xref:install:index.adoc[installation guides] for details.'
@@ -1243,8 +1243,10 @@ async function handleRpcnConnectorDocs (options) {
1243
1243
  // Main Processing: Handle the latest version (final iteration)
1244
1244
  // ========================================================================
1245
1245
 
1246
- console.log('Generating connector partials...')
1247
- let partialsWritten, partialFiles
1246
+ // options.partials is false with --no-partials: the connect repo publishes
1247
+ // the generated partials, so this run only updates release-level content.
1248
+ const writePartials = options.partials !== false
1249
+ let partialsWritten = 0, partialFiles = []
1248
1250
  const descriptionReports = []
1249
1251
  const lostSectionWarnings = []
1250
1252
  const styleRegressionWarnings = []
@@ -1256,7 +1258,10 @@ async function handleRpcnConnectorDocs (options) {
1256
1258
  const { collectGeneratorReports } = require('./pr-summary-formatter.js')
1257
1259
  const collect = (result) => collectGeneratorReports(result, { descriptionReports, lostSectionWarnings, styleRegressionWarnings })
1258
1260
 
1259
- try {
1261
+ if (!writePartials) {
1262
+ console.log('Skipping connector partials (--no-partials): the connect repo provides them.')
1263
+ } else try {
1264
+ console.log('Generating connector partials...')
1260
1265
  const result = await generateRpcnConnectorDocs({
1261
1266
  data: dataFile,
1262
1267
  overrides: options.overrides,
@@ -1511,20 +1516,20 @@ async function handleRpcnConnectorDocs (options) {
1511
1516
  }
1512
1517
  }
1513
1518
 
1514
- // Publish merged version to attachments
1519
+ // Publish the connector data to attachments. The Bloblang playground and
1520
+ // other site tools read this file, so it is published on every run, with
1521
+ // overrides merged in only when an overrides file still exists.
1515
1522
  // IMPORTANT: This must run AFTER binary analysis and augmentation to include CGO-only connectors
1516
- if (options.overrides && fs.existsSync(options.overrides)) {
1523
+ {
1517
1524
  try {
1518
- const { mergeOverrides, resolveReferences } = require('./generate-rpcn-connector-docs.js')
1519
-
1520
1525
  // Use the augmented newIndex which now includes CGO-only and cloud-only connectors
1521
1526
  const mergedData = JSON.parse(JSON.stringify(newIndex))
1522
1527
 
1523
- const ovRaw = fs.readFileSync(options.overrides, 'utf8')
1524
- const ovObj = JSON.parse(ovRaw)
1525
- const resolvedOverrides = resolveReferences(ovObj, ovObj)
1526
-
1527
- mergeOverrides(mergedData, resolvedOverrides)
1528
+ if (options.overrides && fs.existsSync(options.overrides)) {
1529
+ const { mergeOverrides, resolveReferences } = require('./generate-rpcn-connector-docs.js')
1530
+ const ovObj = JSON.parse(fs.readFileSync(options.overrides, 'utf8'))
1531
+ mergeOverrides(mergedData, resolveReferences(ovObj, ovObj))
1532
+ }
1528
1533
 
1529
1534
  const attachmentsRoot = path.resolve(process.cwd(), 'modules/components/attachments')
1530
1535
  fs.mkdirSync(attachmentsRoot, { recursive: true })
@@ -2066,6 +2071,7 @@ async function handleRpcnConnectorDocs (options) {
2066
2071
  templateDescription: options.templateDescription,
2067
2072
  templateIntro: options.templateIntro,
2068
2073
  writeFullDrafts: true,
2074
+ writePartials,
2069
2075
  cgoOnly: binaryAnalysis?.cgoOnly || [],
2070
2076
  cloudOnly: binaryAnalysis?.comparison?.cloudOnly || [],
2071
2077
  csvMetadata
@@ -1,8 +1,14 @@
1
1
  = {{{name}}}
2
2
  // tag::single-source[]
3
- :type: {{type}}
4
- {{#if support}}:support: {{support}}{{/if}}
3
+ {{!-- Drafts are written once and never regenerated, so they hold no
4
+ reference facts that change with the source. The type comes from the
5
+ page directory, the support level and Cloud availability from the
6
+ catalog data, and, when the connect repo publishes the partials
7
+ (connectPartials), the categories from its catalog.json and the status
8
+ and cgo flags from the description partial's meta region. --}}
9
+ {{#unless connectPartials}}
5
10
  :categories: [{{join categories ", "}}]
11
+ {{/unless}}
6
12
  {{#if hasDescriptionMeta}}
7
13
  // tag::meta[]
8
14
  {{{descriptionInclude this "meta"}}}
@@ -27,6 +33,10 @@
27
33
  (cloud-docs .github/description-allowlist.txt). --}}
28
34
 
29
35
  component_type_dropdown::[]
36
+ {{#if connectPartials}}
37
+
38
+ include::connect:components:partial$availability/{{typeDir}}/{{name}}.adoc[opts=optional]
39
+ {{/if}}
30
40
 
31
41
  {{#if hasDescriptionPartial}}
32
42
  {{{descriptionInclude this "body"}}}
@@ -58,6 +68,7 @@ include::components:example$advanced/{{typeDir}}/{{name}}.yaml[]
58
68
  ======
59
69
  {{/if}}
60
70
 
71
+ {{#unless connectPartials}}
61
72
  {{#if requiresCgo}}
62
73
  == Requirements
63
74
 
@@ -71,6 +82,7 @@ You can either xref:install:prebuilt-binary.adoc[download a prebuilt cgo-enabled
71
82
  {{/if}}
72
83
 
73
84
  {{/if}}
85
+ {{/unless}}
74
86
  {{#if (or this.config.children children)}}
75
87
  include::connect:components:partial$fields/{{typeDir}}/{{name}}.adoc[]
76
88
  {{/if}}
@@ -78,6 +90,12 @@ include::connect:components:partial$fields/{{typeDir}}/{{name}}.adoc[]
78
90
  {{#if this.examples}}
79
91
  include::connect:components:partial$examples/{{typeDir}}/{{name}}.adoc[]
80
92
  {{/if}}
93
+ {{#if connectPartials}}
94
+ {{#if hasDescriptionPartial}}
95
+
96
+ {{{descriptionInclude this "footnotes"}}}
97
+ {{/if}}
98
+ {{/if}}
81
99
 
82
100
 
83
101
  // end::single-source[]
@@ -0,0 +1,242 @@
1
+ // Orchestrate release-notes generation: parse the body, render a candidate
2
+ // section, and insert it into the target page under the mechanical guards
3
+ // (GA-only, floor, idempotency). Pure and IO-free: callers pass the page
4
+ // content in and get the updated content back; the doc-tools action does the
5
+ // file read/write, and the workflow fetches the release body.
6
+
7
+ const { parseReleaseBody } = require('./parse-release-body');
8
+ const { renderReleaseSection, normalizeVersion } = require('./render-release-notes');
9
+
10
+ const LOG_TAG = '[release-notes]';
11
+
12
+ /**
13
+ * Compares two `X.Y.Z` versions numerically.
14
+ *
15
+ * @param {string} a - A version (optionally v-prefixed).
16
+ * @param {string} b - A version (optionally v-prefixed).
17
+ * @return {number} -1 if a < b, 0 if equal, 1 if a > b.
18
+ */
19
+ function compareVersions(a, b) {
20
+ const pa = normalizeVersion(a).split('.').map(Number);
21
+ const pb = normalizeVersion(b).split('.').map(Number);
22
+ for (let i = 0; i < 3; i++) {
23
+ const x = pa[i] || 0;
24
+ const y = pb[i] || 0;
25
+ if (x < y) return -1;
26
+ if (x > y) return 1;
27
+ }
28
+ return 0;
29
+ }
30
+
31
+ /**
32
+ * Reports whether a tag is a GA release. Release notes are added for GA tags
33
+ * only; a prerelease (for example `v26.2.3-rc1`) gets no section.
34
+ *
35
+ * @param {string} tag - The release tag.
36
+ * @return {boolean} True if the tag is a bare `vX.Y.Z` with no prerelease suffix.
37
+ */
38
+ function isGaTag(tag) {
39
+ return /^v?\d+\.\d+\.\d+$/.test(String(tag || '').trim());
40
+ }
41
+
42
+ /**
43
+ * Reads the `:earliest-tracked-version:` floor attribute from the page.
44
+ *
45
+ * @param {string} pageContent - The target page content.
46
+ * @return {string|null} The bare floor version, or null if the attribute is absent.
47
+ */
48
+ function readFloor(pageContent) {
49
+ const m = String(pageContent).match(/^:earliest-tracked-version:\s*(.+?)\s*$/m);
50
+ return m ? normalizeVersion(m[1]) : null;
51
+ }
52
+
53
+ /**
54
+ * Reports whether the page already has a section for this version, so a re-run
55
+ * for an already-published tag is a no-op rather than a duplicate.
56
+ *
57
+ * @param {string} pageContent - The target page content.
58
+ * @param {string} version - The release version (optionally v-prefixed).
59
+ * @return {boolean} True if a `== vX.Y.Z (` heading is already present.
60
+ */
61
+ function hasVersionSection(pageContent, version) {
62
+ const v = normalizeVersion(version).replace(/[.]/g, '\\.');
63
+ return new RegExp(`^==\\s+v${v}\\s*\\(`, 'm').test(String(pageContent));
64
+ }
65
+
66
+ /**
67
+ * Inserts a rendered section into the page in descending-version order: before
68
+ * the first existing `== vX.Y.Z` heading OLDER than the section, so a later
69
+ * backfill of an older release lands below the newer ones already present
70
+ * rather than jumping to the top. When the section is older than every existing
71
+ * release, it is inserted AFTER the last version section — before the next
72
+ * top-level heading following it (the older-versions footer or any other), else
73
+ * at the end — so a footerless page stays ordered. When there are no version
74
+ * sections yet, it goes before the older-versions footer, else the first
75
+ * top-level heading, else the end. The section's own version is read with
76
+ * multiline matching, so a leading blank line or comment does not hide it.
77
+ *
78
+ * @param {string} pageContent - The target page content.
79
+ * @param {string} sectionText - The rendered section (ending in a newline).
80
+ * @return {string} The page content with the section inserted.
81
+ */
82
+ function insertReleaseSection(pageContent, sectionText) {
83
+ const lines = String(pageContent).split('\n');
84
+ const sectionLines = sectionText.replace(/\n+$/, '').split('\n');
85
+ const versionHeading = /^==\s+v(\d+\.\d+\.\d+)\s*\(/;
86
+ // Multiline: the curated section may open with a blank line or an AsciiDoc
87
+ // comment, so the heading is not guaranteed to be at character zero.
88
+ const newVer = (sectionText.match(/^==\s+v(\d+\.\d+\.\d+)\s*\(/m) || [])[1];
89
+
90
+ const insertAt = (i) => {
91
+ // The anchor heading is already preceded by a blank line, so the section
92
+ // plus one blank separator keeps exactly one blank line on each side.
93
+ lines.splice(i, 0, ...sectionLines, '');
94
+ return lines.join('\n');
95
+ };
96
+ const appendAtEnd = () => {
97
+ const tail = lines[lines.length - 1] === '' ? [] : [''];
98
+ return [...lines, ...tail, ...sectionLines, ''].join('\n');
99
+ };
100
+
101
+ // 1) Descending order: before the first existing section older than this one.
102
+ // A new newest release lands at the top this way too.
103
+ if (newVer) {
104
+ for (let i = 0; i < lines.length; i++) {
105
+ const m = lines[i].match(versionHeading);
106
+ if (m && compareVersions(newVer, m[1]) > 0) return insertAt(i);
107
+ }
108
+ }
109
+
110
+ // 2) Older than every existing section (or version unknown): sit AFTER the
111
+ // last version section — before the next top-level heading that follows it
112
+ // (the older-versions footer or any other), else at the end. This keeps a
113
+ // footerless page ordered correctly instead of jumping to the top.
114
+ let lastVersionIdx = -1;
115
+ for (let i = 0; i < lines.length; i++) {
116
+ if (versionHeading.test(lines[i])) lastVersionIdx = i;
117
+ }
118
+ if (lastVersionIdx !== -1) {
119
+ for (let i = lastVersionIdx + 1; i < lines.length; i++) {
120
+ if (/^==\s/.test(lines[i])) return insertAt(i);
121
+ }
122
+ return appendAtEnd();
123
+ }
124
+
125
+ // 3) No version sections at all: before the older-versions footer, else
126
+ // before the first top-level heading, else at the end.
127
+ const footerIdx = lines.findIndex((l) => /^==\s+Release notes for older versions/i.test(l));
128
+ if (footerIdx !== -1) return insertAt(footerIdx);
129
+ const firstHeadingIdx = lines.findIndex((l) => /^==\s/.test(l));
130
+ if (firstHeadingIdx !== -1) return insertAt(firstHeadingIdx);
131
+ return appendAtEnd();
132
+ }
133
+
134
+ /**
135
+ * Builds a candidate release section from a raw release body.
136
+ *
137
+ * @param {Object} options
138
+ * @param {string} options.body - The raw rpchangelog release body markdown.
139
+ * @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
140
+ * @param {string} options.date - The release date as `YYYY-MM-DD`.
141
+ * @return {string} The rendered AsciiDoc section.
142
+ */
143
+ function buildReleaseSection({ body, version, date }) {
144
+ const { sections } = parseReleaseBody(body);
145
+ return renderReleaseSection({ version, date, sections });
146
+ }
147
+
148
+ /**
149
+ * Asserts that an externally-produced (curated) section carries exactly one
150
+ * `== vX.Y.Z (date)` heading whose version matches the requested tag. Phase 2
151
+ * inserts the section verbatim, and the idempotency guard keys off the tag, so
152
+ * a heading that disagrees with the tag would defeat idempotency and let
153
+ * reruns insert duplicates. This is malformed input, so it throws (fail loud)
154
+ * rather than returning a skip.
155
+ *
156
+ * @param {string} section - The curated section text.
157
+ * @param {string} version - The bare `X.Y.Z` version the tag resolved to.
158
+ * @throws {Error} If the section has zero or several release headings, or one
159
+ * whose version does not match.
160
+ */
161
+ function assertSectionMatchesTag(section, version) {
162
+ // Count heading-ish lines loosely (any `== v<digit>…`) so a single MALFORMED
163
+ // heading fails as malformed rather than slipping through as "found 0".
164
+ const headingish = String(section).match(/^==\s+v\d.*$/gm) || [];
165
+ if (headingish.length !== 1) {
166
+ throw new Error(`Curated section must contain exactly one "== vX.Y.Z (YYYY-MM-DD)" heading; found ${headingish.length}.`);
167
+ }
168
+ // Validate the complete heading: version, a YYYY-MM-DD date, a closing paren,
169
+ // and nothing trailing.
170
+ const m = headingish[0].match(/^==\s+v(\d+\.\d+\.\d+)\s+\(\d{4}-\d{2}-\d{2}\)\s*$/);
171
+ if (!m) {
172
+ throw new Error(`Malformed release heading ${JSON.stringify(headingish[0])}; expected "== vX.Y.Z (YYYY-MM-DD)".`);
173
+ }
174
+ if (m[1] !== version) {
175
+ throw new Error(`Curated section heading (v${m[1]}) does not match the requested tag (v${version}).`);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Generates the updated page content for a release, applying the GA, floor, and
181
+ * idempotency guards. Never throws for a guard miss: returns a `skipped` status
182
+ * so the workflow can no-op cleanly.
183
+ *
184
+ * Two phases, because the LLM curation step sits between them:
185
+ * - Phase 1 (pass `body`): build the de-noised candidate section from the raw
186
+ * release body. This candidate is the curation step's input.
187
+ * - Phase 2 (pass `section`): insert the curated section the skill produced.
188
+ * Exactly one of `body` or `section` is supplied. The guards apply in both
189
+ * phases, since they depend on the tag and the page, not on the entries.
190
+ *
191
+ * @param {Object} options
192
+ * @param {string} [options.body] - Raw rpchangelog release body (phase 1).
193
+ * @param {string} [options.section] - A pre-built/curated section to insert (phase 2).
194
+ * @param {string} options.tag - The release tag (`v26.2.3`).
195
+ * @param {string} [options.date] - The authored release date as `YYYY-MM-DD` (phase 1 only).
196
+ * @param {string} options.pageContent - The current target page content.
197
+ * @return {{status: 'ok'|'skipped', reason?: string, section?: string, content?: string}}
198
+ */
199
+ function generateReleaseNotes({ body, section, tag, date, pageContent }) {
200
+ if (body == null && section == null) {
201
+ throw new Error('Provide either body (phase 1) or section (phase 2).');
202
+ }
203
+ if (body != null && section != null) {
204
+ throw new Error('Provide only one of body or section, not both.');
205
+ }
206
+
207
+ if (!isGaTag(tag)) {
208
+ return { status: 'skipped', reason: `not a GA tag: ${tag}` };
209
+ }
210
+ const version = normalizeVersion(tag);
211
+
212
+ const floor = readFloor(pageContent);
213
+ if (floor && compareVersions(version, floor) <= 0) {
214
+ return { status: 'skipped', reason: `version ${version} is not newer than the floor ${floor}` };
215
+ }
216
+
217
+ if (hasVersionSection(pageContent, version)) {
218
+ return { status: 'skipped', reason: `page already has a section for v${version}` };
219
+ }
220
+
221
+ let finalSection;
222
+ if (section != null) {
223
+ assertSectionMatchesTag(section, version);
224
+ finalSection = section;
225
+ } else {
226
+ finalSection = buildReleaseSection({ body, version, date });
227
+ }
228
+ const content = insertReleaseSection(pageContent, finalSection);
229
+ return { status: 'ok', section: finalSection, content };
230
+ }
231
+
232
+ module.exports = {
233
+ generateReleaseNotes,
234
+ buildReleaseSection,
235
+ assertSectionMatchesTag,
236
+ insertReleaseSection,
237
+ compareVersions,
238
+ isGaTag,
239
+ readFloor,
240
+ hasVersionSection,
241
+ LOG_TAG,
242
+ };