@redpanda-data/docs-extensions-and-macros 5.50.0 → 5.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,9 +37,14 @@ function escapeRegExp(str) {
37
37
  * Regex pattern to match and strip the llms directive from markdown content.
38
38
  * Dynamically derived from LLMS_DIRECTIVE_BASE to stay in sync.
39
39
  * Matches the blockquote format with optional component-specific suffix.
40
+ * The directive is written with a root-relative link, but links are made
41
+ * absolute before the exports strip it, so any prefix before /llms.txt matches.
40
42
  */
41
43
  const LLMS_DIRECTIVE_REGEX = new RegExp(
42
- `^> ${escapeRegExp(LLMS_DIRECTIVE_BASE)}.*$`,
44
+ `^> ${escapeRegExp(LLMS_DIRECTIVE_BASE).replace(
45
+ escapeRegExp('(/llms.txt)'),
46
+ '\\([^)\\s]*/llms\\.txt\\)'
47
+ )}.*$`,
43
48
  'gm'
44
49
  );
45
50
 
@@ -96,11 +101,343 @@ function componentsWithExports(components, pages) {
96
101
  );
97
102
  }
98
103
 
104
+ /**
105
+ * Maximum characters per page index file. The Agent-Friendly Documentation
106
+ * Spec passes an llms.txt index at 50,000 characters or less and recommends
107
+ * splitting a large index into section-level files that each stay under that
108
+ * limit. Leave a buffer below 50K.
109
+ */
110
+ const MAX_INDEX_CHARS = 45000;
111
+
112
+ /**
113
+ * Descriptions longer than this are cut at a word boundary so that one verbose
114
+ * page doesn't force an extra split.
115
+ */
116
+ const MAX_INDEX_DESCRIPTION_CHARS = 200;
117
+
118
+ function oneLine(text) {
119
+ return String(text || '')
120
+ .replace(/<[^>]+>/g, '')
121
+ .replace(/\s+/g, ' ')
122
+ .trim();
123
+ }
124
+
125
+ function truncateDescription(text) {
126
+ const clean = oneLine(text);
127
+ if (clean.length <= MAX_INDEX_DESCRIPTION_CHARS) return clean;
128
+ const cut = clean.slice(0, MAX_INDEX_DESCRIPTION_CHARS);
129
+ const lastSpace = cut.lastIndexOf(' ');
130
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[\s.,;:]+$/, '')}...`;
131
+ }
132
+
133
+ // Square brackets in link text end the link early in markdown.
134
+ function escapeLinkText(text) {
135
+ return oneLine(text).replace(/([[\]])/g, '\\$1');
136
+ }
137
+
138
+ /**
139
+ * A URL made safe as a Markdown link destination. An unmatched `)` ends the
140
+ * destination early, and a space or `<` ends it outright, so those are
141
+ * percent-encoded. Encoding keeps the plain `[text](url)` shape llms.txt
142
+ * readers expect, where an angle-bracket destination would not.
143
+ */
144
+ function linkDestination(url) {
145
+ return String(url).replace(/[()<> ]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
146
+ }
147
+
148
+ /**
149
+ * Longest shared directory of a set of URL paths, always ending in `/`.
150
+ * `/streaming/26.1/manage/a/` and `/streaming/26.1/deploy/b/` share `/streaming/26.1/`.
151
+ */
152
+ function commonDirectory(urls) {
153
+ const dirs = urls.map((url) => url.replace(/[^/]*\/?$/, '').split('/').filter(Boolean));
154
+ if (!dirs.length) return '/';
155
+ const shared = [];
156
+ for (let i = 0; i < dirs[0].length; i++) {
157
+ const segment = dirs[0][i];
158
+ if (!dirs.every((d) => d[i] === segment)) break;
159
+ shared.push(segment);
160
+ }
161
+ return shared.length ? `/${shared.join('/')}/` : '/';
162
+ }
163
+
164
+ /**
165
+ * URL of the component version's root, derived from how Antora built the page
166
+ * URL: the version root, then the module name (omitted for ROOT), then the
167
+ * page's relative path (index pages drop their own segment). Works for both
168
+ * indexified (`/a/b/`) and `.html` URLs, since the page is one segment either
169
+ * way, except an index page, whose `.html` URL has an `index.html` segment
170
+ * that its indexified URL does not.
171
+ * Returns null when the page lacks the source fields needed.
172
+ */
173
+ function versionRootOf(page) {
174
+ const { module: moduleName, relative } = page.src || {};
175
+ if (!relative || !moduleName) return null;
176
+ const relSegments = relative.replace(/\.adoc$/, '').split('/').filter(Boolean);
177
+ const urlSegments = page.pub.url.split('/').filter(Boolean);
178
+ if (relSegments[relSegments.length - 1] === 'index') {
179
+ relSegments.pop();
180
+ // An index page keeps an `index.html` segment of its own when URLs are
181
+ // not indexified (`/streaming/26.1/index.html`), and has none when they
182
+ // are (`/streaming/26.1/`).
183
+ if (urlSegments[urlSegments.length - 1] === 'index.html') urlSegments.pop();
184
+ }
185
+ const tailCount = relSegments.length + (moduleName === 'ROOT' ? 0 : 1);
186
+ if (tailCount > urlSegments.length) return null;
187
+ const rootSegments = urlSegments.slice(0, urlSegments.length - tailCount);
188
+ return rootSegments.length ? `/${rootSegments.join('/')}/` : '/';
189
+ }
190
+
191
+ function mostCommon(values) {
192
+ const counts = new Map();
193
+ values.forEach((v) => counts.set(v, (counts.get(v) || 0) + 1));
194
+ let best = null;
195
+ counts.forEach((count, value) => {
196
+ if (best === null || count > counts.get(best)) best = value;
197
+ });
198
+ return best;
199
+ }
200
+
201
+ /**
202
+ * The directory segment directly under `dir` that contains `url`, or null when
203
+ * the page sits in `dir` itself. With indexified URLs a page `/a/b/` lives in
204
+ * `/a/`, so the last segment is the page, not a directory.
205
+ */
206
+ function childSegment(url, dir) {
207
+ const rest = url.slice(dir.length).replace(/\/$/, '').replace(/\/index\.html$/, '');
208
+ const segments = rest.split('/').filter(Boolean);
209
+ return segments.length > 1 ? segments[0] : null;
210
+ }
211
+
212
+ function latestDate(dates) {
213
+ const times = dates.filter(Boolean).map((d) => new Date(d)).filter((d) => !isNaN(d));
214
+ return times.length ? new Date(Math.max(...times)).toISOString() : null;
215
+ }
216
+
217
+ function renderIndexFile({ heading, intro, entries }) {
218
+ let out = `# ${heading}\n\n`;
219
+ intro.forEach((line) => {
220
+ out += `> ${line}\n`;
221
+ });
222
+ out += `\n## Pages\n\n`;
223
+ entries.forEach((entry) => {
224
+ out += `${entry.line}\n`;
225
+ });
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * Pack entries into chunks whose rendered size stays under the limit.
231
+ * Used only when a single directory holds more pages than one file allows.
232
+ */
233
+ function chunkEntries(entries, overhead, maxChars) {
234
+ const chunks = [];
235
+ let current = [];
236
+ let size = overhead;
237
+ entries.forEach((entry) => {
238
+ const lineSize = entry.line.length + 1;
239
+ if (current.length && size + lineSize > maxChars) {
240
+ chunks.push(current);
241
+ current = [];
242
+ size = overhead;
243
+ }
244
+ current.push(entry);
245
+ size += lineSize;
246
+ });
247
+ if (current.length) chunks.push(current);
248
+ return chunks;
249
+ }
250
+
251
+ /**
252
+ * Build page index files for every published component version.
253
+ *
254
+ * Each component version gets an index at `<version root>/llms.txt` listing
255
+ * every page as a markdown link to its .md URL. When a version's index would
256
+ * exceed `maxChars`, it is split by URL directory, recursively, so each file
257
+ * stays under the limit. The root llms.txt links to every file this returns,
258
+ * which keeps every page one hop from the root index.
259
+ *
260
+ * @param {Object} options
261
+ * @param {Array} options.pages - Antora pages that have markdown (page.markdownContents)
262
+ * @param {Array} options.components - Antora components
263
+ * @param {string} options.siteUrl - Absolute site URL without a trailing slash
264
+ * @param {Function} options.toMarkdownUrl - Maps a page's HTML URL to its .md URL
265
+ * @param {number} [options.maxChars] - Size limit per file
266
+ * @returns {Array<{path: string, url: string, title: string, component: string,
267
+ * version: string, isLatest: boolean, pageCount: number, contents: string}>}
268
+ */
269
+ function buildPageIndexes({ pages, components, siteUrl, toMarkdownUrl, maxChars = MAX_INDEX_CHARS }) {
270
+ const componentsByName = new Map((components || []).map((c) => [c.name, c]));
271
+ const groups = new Map();
272
+
273
+ (pages || []).forEach((page) => {
274
+ const url = page.pub && page.pub.url;
275
+ if (!url || !page.out || !page.src) return;
276
+ const key = `${page.src.version}@${page.src.component}`;
277
+ if (!groups.has(key)) groups.set(key, []);
278
+ groups.get(key).push(page);
279
+ });
280
+
281
+ const indexes = [];
282
+
283
+ groups.forEach((groupPages) => {
284
+ const { component: componentName, version } = groupPages[0].src;
285
+ const component = componentsByName.get(componentName);
286
+ if (!component) return;
287
+ const componentVersion = (component.versions || []).find((v) => v.version === version) || {};
288
+ const latest = component.latest || (component.versions || [])[0] || {};
289
+ const isLatest = latest.version === version;
290
+ const versioned = (component.versions || []).length > 1;
291
+ const displayVersion = componentVersion.displayVersion || version;
292
+ const baseTitle = versioned
293
+ ? `${component.title} ${displayVersion}`
294
+ : component.title;
295
+
296
+ const entries = groupPages
297
+ .map((page) => {
298
+ const title = page.asciidoc && (page.asciidoc.doctitle || page.asciidoc.navtitle);
299
+ const description = page.asciidoc && page.asciidoc.attributes && page.asciidoc.attributes.description;
300
+ const mdUrl = `${siteUrl}${toMarkdownUrl(page.pub.url)}`;
301
+ let line = `- [${escapeLinkText(title || page.src.stem)}](${linkDestination(mdUrl)})`;
302
+ if (description) line += `: ${truncateDescription(description)}`;
303
+ const modified = page.asciidoc && page.asciidoc.attributes && page.asciidoc.attributes['page-git-modified-date'];
304
+ return { url: page.pub.url, line, modified };
305
+ })
306
+ .sort((a, b) => a.url.localeCompare(b.url));
307
+
308
+ const roots = groupPages.map(versionRootOf).filter(Boolean);
309
+ const versionRoot = roots.length ? mostCommon(roots) : commonDirectory(entries.map((e) => e.url));
310
+
311
+ const intro = (scope) => {
312
+ const lines = [
313
+ `Index of ${scope} documentation pages. Each link points to the page's markdown version.`,
314
+ ];
315
+ if (versioned && !isLatest) {
316
+ lines.push(`This is version ${displayVersion}. The latest version is ${latest.displayVersion || latest.version}.`);
317
+ }
318
+ lines.push(`For the full documentation index, see ${siteUrl}/llms.txt`);
319
+ return lines;
320
+ };
321
+
322
+ const makeIndex = (dir, dirEntries, label, part) => {
323
+ const heading = label ? `${baseTitle}: ${label}` : baseTitle;
324
+ const title = part > 1 ? `${heading} (part ${part})` : heading;
325
+ const path = `${dir.replace(/^\//, '')}${part > 1 ? `llms-${part}.txt` : 'llms.txt'}`;
326
+ return {
327
+ path,
328
+ url: `${siteUrl}/${path}`,
329
+ title,
330
+ component: componentName,
331
+ version,
332
+ isLatest,
333
+ pageCount: dirEntries.length,
334
+ lastModified: latestDate(dirEntries.map((e) => e.modified)),
335
+ contents: renderIndexFile({
336
+ heading: title,
337
+ intro: intro(label ? `${baseTitle} ${label}` : baseTitle),
338
+ entries: dirEntries,
339
+ }),
340
+ };
341
+ };
342
+
343
+ const split = (dir, dirEntries) => {
344
+ const label = dir.slice(versionRoot.length).replace(/\/$/, '');
345
+ const whole = makeIndex(dir, dirEntries, label, 1);
346
+ if (whole.contents.length <= maxChars) {
347
+ indexes.push(whole);
348
+ return;
349
+ }
350
+
351
+ const children = new Map();
352
+ dirEntries.forEach((entry) => {
353
+ const child = childSegment(entry.url, dir);
354
+ if (child === null) return;
355
+ if (!children.has(child)) children.set(child, []);
356
+ children.get(child).push(entry);
357
+ });
358
+
359
+ // Peel off the largest subdirectories into their own indexes until the
360
+ // rest fits. Small subdirectories stay in this directory's index, so the
361
+ // split produces as few files as the limit allows.
362
+ const fits = (list) => makeIndex(dir, list, label, 1).contents.length <= maxChars;
363
+ const bySize = [...children.entries()].sort((a, b) => b[1].length - a[1].length);
364
+ const kept = new Set(bySize.map(([child]) => child));
365
+ const remaining = () => dirEntries.filter((entry) => {
366
+ const child = childSegment(entry.url, dir);
367
+ return child === null || kept.has(child);
368
+ });
369
+ for (const [child, childEntries] of bySize) {
370
+ if (fits(remaining())) break;
371
+ kept.delete(child);
372
+ split(`${dir}${child}/`, childEntries);
373
+ }
374
+
375
+ const rest = remaining();
376
+ if (!rest.length) return;
377
+ // A directory whose own pages still exceed the limit is split into numbered parts.
378
+ // Header size of the largest possible part, so no chunk overflows once rendered.
379
+ const overhead = makeIndex(dir, [], label, 99).contents.length;
380
+ chunkEntries(rest, overhead, maxChars).forEach((chunk, i) => indexes.push(makeIndex(dir, chunk, label, i + 1)));
381
+ };
382
+
383
+ split(versionRoot, entries);
384
+ });
385
+
386
+ // Latest versions first, then older versions newest first, each in URL order.
387
+ return indexes.sort((a, b) => {
388
+ if (a.component !== b.component) return a.component.localeCompare(b.component);
389
+ if (a.isLatest !== b.isLatest) return a.isLatest ? -1 : 1;
390
+ if (a.version !== b.version) return b.version.localeCompare(a.version, undefined, { numeric: true });
391
+ return a.path.localeCompare(b.path);
392
+ });
393
+ }
394
+
395
+ /**
396
+ * Replace markdown links to the root llms.txt with their plain link text.
397
+ * A root index that links to itself is read as a nested index by tools that
398
+ * walk llms.txt, which pushes every page index one level deeper than intended.
399
+ */
400
+ function unlinkSelfReferences(content, siteUrl) {
401
+ const self = new RegExp(`\\[([^\\]]*)\\]\\((?:${escapeRegExp(siteUrl)})?/llms\\.txt\\)`, 'g');
402
+ return content.replace(self, '$1');
403
+ }
404
+
405
+ /**
406
+ * Render the root llms.txt section that links to every page index file.
407
+ * Every index must be linked from the root so agents, and tools that follow
408
+ * one level of nested indexes, can reach every page.
409
+ */
410
+ function renderPageIndexSection(indexes, components) {
411
+ const titles = new Map((components || []).map((c) => [c.name, c.title]));
412
+ let out = `## Page indexes\n\n`;
413
+ out += `Each file below lists pages as links to their markdown versions. Start with the latest version of a product unless you need a specific older version.\n\n`;
414
+ let currentComponent = null;
415
+ let olderHeadingShown = false;
416
+ indexes.forEach((index) => {
417
+ if (index.component !== currentComponent) {
418
+ currentComponent = index.component;
419
+ olderHeadingShown = false;
420
+ out += `${currentComponent === indexes[0].component ? '' : '\n'}### ${titles.get(index.component) || index.component}\n\n`;
421
+ }
422
+ if (!index.isLatest && !olderHeadingShown) {
423
+ olderHeadingShown = true;
424
+ out += `\nOlder versions:\n\n`;
425
+ }
426
+ out += `- [${escapeLinkText(index.title)}](${linkDestination(index.url)}): ${index.pageCount} ${index.pageCount === 1 ? 'page' : 'pages'}\n`;
427
+ });
428
+ return out;
429
+ }
430
+
99
431
  module.exports = {
100
432
  LLMS_DIRECTIVE_BASE,
101
433
  LLMS_DIRECTIVE_REGEX,
102
434
  SOURCE_COMMENT_REGEX,
435
+ MAX_INDEX_CHARS,
103
436
  formatLlmsDirective,
437
+ linkDestination,
104
438
  stripMarkdownMetadata,
105
439
  componentsWithExports,
440
+ buildPageIndexes,
441
+ renderPageIndexSection,
442
+ unlinkSelfReferences,
106
443
  };
@@ -1267,6 +1267,7 @@ The `convert-llms-to-txt` extension generates AI-optimized documentation exports
1267
1267
  - Generates `llms.txt` from a curated `llms.adoc` page in the home component
1268
1268
  - Creates `llms-full.txt` with all documentation pages
1269
1269
  - Generates component-specific exports (e.g., `ROOT-full.txt`, `cloud-full.txt`)
1270
+ - Generates a page index (`llms.txt`) for every published component version, linked from the root `llms.txt`
1270
1271
  - Unpublishes the HTML version of llms.adoc
1271
1272
  - Replaces site-url attribute dynamically for preview builds
1272
1273
 
@@ -1290,7 +1291,8 @@ Create `modules/home/pages/llms.adoc` in your docs repository with curated docum
1290
1291
  2. Place `llms.txt` at site root
1291
1292
  3. Generate `llms-full.txt` with all pages
1292
1293
  4. Generate component-specific full.txt files
1293
- 5. Unpublish the HTML version
1294
+ 5. Generate a page index for every published component version and link each one from `llms.txt`
1295
+ 6. Unpublish the HTML version
1294
1296
 
1295
1297
  === Generated files
1296
1298
 
@@ -1299,6 +1301,16 @@ Create `modules/home/pages/llms.adoc` in your docs repository with curated docum
1299
1301
  - `{site-url}/ROOT-full.txt` - Self-Managed docs only
1300
1302
  - `{site-url}/cloud-full.txt` - Cloud docs only
1301
1303
  - `{site-url}/redpanda-connect-full.txt` - Connect docs only
1304
+ - `{site-url}/<component>/<version>/llms.txt` - Page index for one component version
1305
+ - `{site-url}/<component>/llms.txt` - Page index for a component without versions, such as `connect/llms.txt`
1306
+
1307
+ === Page indexes
1308
+
1309
+ Each page index lists every published page in one component version as a markdown link to the page's `.md` URL, followed by the page description when it has one. Older versions get their own indexes, which say which version they cover and name the latest version.
1310
+
1311
+ An index that would exceed 45,000 characters is split by URL directory, for example `streaming/current/manage/llms.txt`. A directory that is still too large on its own is split into numbered parts (`llms.txt`, `llms-2.txt`, and so on). The Agent-Friendly Documentation Spec passes an `llms.txt` at 50,000 characters or less and recommends this kind of split for large sites.
1312
+
1313
+ The root `llms.txt` links to every page index in a *Page indexes* section. The index list is never truncated, because an index missing from the root leaves its pages unreachable from `llms.txt`. Page indexes do not link to other page indexes, so every page is one hop from the root.
1302
1314
 
1303
1315
  === Attributes
1304
1316
 
@@ -3,7 +3,13 @@
3
3
  const { raiseListenerLimit } = require('./util/raise-listener-limit')
4
4
 
5
5
  const { toMarkdownUrl } = require('../extension-utils/url-utils');
6
- const { stripMarkdownMetadata, componentsWithExports } = require('../extension-utils/llms-utils');
6
+ const {
7
+ stripMarkdownMetadata,
8
+ componentsWithExports,
9
+ buildPageIndexes,
10
+ renderPageIndexSection,
11
+ unlinkSelfReferences,
12
+ } = require('../extension-utils/llms-utils');
7
13
 
8
14
  /**
9
15
  * Extracts markdown from llms.adoc page and generates AI-friendly documentation exports.
@@ -18,6 +24,9 @@ const { stripMarkdownMetadata, componentsWithExports } = require('../extension-u
18
24
  * 5. Places llms.txt (markdown) at site root
19
25
  * 6. Generates llms-full.txt with markdown from latest versions of all components
20
26
  * 7. Generates component-specific full.txt files (e.g., redpanda-full.txt, cloud-full.txt)
27
+ * 8. Generates page index files (<version root>/llms.txt) for every published component
28
+ * version, split by URL directory to stay under the size limit, and links each
29
+ * one from the root llms.txt so every page is one hop from the root index
21
30
  *
22
31
  * Must run after convert-to-markdown extension to access page.markdownContents.
23
32
  */
@@ -110,6 +119,8 @@ module.exports.register = function () {
110
119
  logger.info('Unpublished llms HTML page');
111
120
  }
112
121
 
122
+ content = unlinkSelfReferences(content, siteUrl);
123
+
113
124
  // Store cleaned markdown content for adding after llms-full.txt
114
125
  llmsPage.llmsTxtContent = content;
115
126
 
@@ -142,145 +153,168 @@ module.exports.register = function () {
142
153
  return latestVersions.has(pageKey);
143
154
  });
144
155
 
145
- if (!pages.length) {
146
- logger.warn('No pages with markdown content found in latest versions, skipping llms-full.txt generation');
156
+ if (!allPages.length) {
157
+ logger.warn('No pages with markdown content found, skipping llms.txt generation');
147
158
  return;
148
159
  }
149
160
 
150
- logger.info(`Filtered to ${pages.length} pages from ${latestVersions.size} latest component versions (from ${allPages.length} total pages)`);
151
-
152
- let fullContent = `# Redpanda Documentation - Full Markdown Export\n\n`;
153
- fullContent += `> This file contains all documentation pages in markdown format for AI agent consumption.\n`;
154
- fullContent += `> Generated from ${pages.length} pages on ${new Date().toISOString()}\n`;
155
- fullContent += `> Site: ${siteUrl}\n\n`;
156
- fullContent += `## About This Export\n\n`;
157
- fullContent += `This export includes only the **latest version** of each component's documentation:\n`;
158
- components.forEach(component => {
159
- const latest = component.latest || component.versions[0];
160
- if (latest) {
161
- fullContent += `- **${component.title}**: version ${latest.version}\n`;
162
- }
163
- });
164
- fullContent += `\n`;
165
- fullContent += `### AI-Friendly Documentation Formats\n\n`;
166
- fullContent += `We provide multiple formats optimized for AI consumption:\n\n`;
167
- fullContent += `- **${siteUrl}/llms.txt**: Curated overview following the llms.txt standard - start here for a quick introduction\n`;
168
- fullContent += `- **${siteUrl}/llms-full.txt**: Complete documentation export (this file) - comprehensive reference with all pages\n`;
169
- fullContent += `- **Component-specific exports**: Focused documentation for individual products:\n`;
170
- // Only advertise exports for components that actually have pages — a
171
- // `<name>-full.txt` is generated per component below, so listing corpus-less
172
- // landing/utility components (e.g. data-platform, self-managed, search) would
173
- // point the AI index at a 404.
174
- componentsWithExports(components, pages).forEach(component => {
175
- fullContent += ` - \`${siteUrl}/${component.name}-full.txt\`: ${component.title}\n`;
176
- });
177
- fullContent += `- **Individual markdown pages**: Each HTML page has a corresponding .md file (e.g., \`/docs/page.html\` → \`/docs/page.md\`)\n\n`;
178
- fullContent += `### Accessing Versioned Content\n\n`;
179
- fullContent += `For components with versioned documentation (like Redpanda Self-Managed), older versions can be accessed by replacing the version segment in the URL:\n`;
180
- fullContent += `- Latest: \`${siteUrl}/current/page-path\`\n`;
181
- fullContent += `- Specific version: \`${siteUrl}/24.3/page-path\`, \`${siteUrl}/25.1/page-path\`, etc.\n\n`;
182
- fullContent += `Available versioned components: ${components.filter(c => c.versions.length > 1).map(c => c.name).join(', ')}\n\n`;
183
- fullContent += `---\n\n`;
184
-
185
- // Sort pages by URL for consistent ordering
186
- pages.sort((a, b) => {
187
- const urlA = a.pub?.url || '';
188
- const urlB = b.pub?.url || '';
189
- return urlA.localeCompare(urlB);
190
- });
191
-
192
- pages.forEach((page, index) => {
193
- const mdUrl = page.pub?.url ? toMarkdownUrl(page.pub.url) : '';
194
- const pageUrl = mdUrl ? `${siteUrl}${mdUrl}` : 'unknown';
195
- const pageTitle = page.asciidoc?.doctitle || page.src?.stem || 'Untitled';
196
-
197
- fullContent += `# Page ${index + 1}: ${pageTitle}\n\n`;
198
- fullContent += `**URL**: ${pageUrl}\n\n`;
199
- fullContent += `---\n\n`;
200
- // Strip metadata (directive, source comments) from page content
201
- fullContent += stripMarkdownMetadata(page.markdownContents);
202
- fullContent += `\n\n---\n\n`;
203
- });
204
-
205
- // Add llms-full.txt to site root
206
- siteCatalog.addFile({
207
- contents: Buffer.from(fullContent, 'utf8'),
208
- out: { path: 'llms-full.txt' },
209
- });
210
- logger.info(`Generated llms-full.txt with ${pages.length} pages`);
211
-
212
- // Generate component-specific full.txt files
213
- logger.info('Generating component-specific full.txt files...');
161
+ // The full exports cover the latest versions only, but page indexes cover
162
+ // every published version, so an empty latest set skips only the exports.
214
163
  const componentGroups = new Map();
215
-
216
- // Group pages by component
217
- pages.forEach(page => {
218
- const componentName = page.src.component;
219
- if (!componentGroups.has(componentName)) {
220
- componentGroups.set(componentName, []);
221
- }
222
- componentGroups.get(componentName).push(page);
223
- });
224
-
225
- // Generate a full.txt file for each component
226
- componentGroups.forEach((componentPages, componentName) => {
227
- const component = components.find(c => c.name === componentName);
228
- if (!component) return;
229
-
230
- const latest = component.latest || component.versions[0];
231
- if (!latest) return;
164
+ if (!pages.length) {
165
+ logger.warn('No pages with markdown content found in latest versions, skipping llms-full.txt generation');
166
+ } else {
167
+ logger.info(`Filtered to ${pages.length} pages from ${latestVersions.size} latest component versions (from ${allPages.length} total pages)`);
168
+
169
+ let fullContent = `# Redpanda Documentation - Full Markdown Export\n\n`;
170
+ fullContent += `> This file contains all documentation pages in markdown format for AI agent consumption.\n`;
171
+ fullContent += `> Generated from ${pages.length} pages on ${new Date().toISOString()}\n`;
172
+ fullContent += `> Site: ${siteUrl}\n\n`;
173
+ fullContent += `## About This Export\n\n`;
174
+ fullContent += `This export includes only the **latest version** of each component's documentation:\n`;
175
+ components.forEach(component => {
176
+ const latest = component.latest || component.versions[0];
177
+ if (latest) {
178
+ fullContent += `- **${component.title}**: version ${latest.version}\n`;
179
+ }
180
+ });
181
+ fullContent += `\n`;
182
+ fullContent += `### AI-Friendly Documentation Formats\n\n`;
183
+ fullContent += `We provide multiple formats optimized for AI consumption:\n\n`;
184
+ fullContent += `- **${siteUrl}/llms.txt**: Curated overview following the llms.txt standard - start here for a quick introduction\n`;
185
+ fullContent += `- **${siteUrl}/llms-full.txt**: Complete documentation export (this file) - comprehensive reference with all pages\n`;
186
+ fullContent += `- **Component-specific exports**: Focused documentation for individual products:\n`;
187
+ // Only advertise exports for components that actually have pages — a
188
+ // `<name>-full.txt` is generated per component below, so listing corpus-less
189
+ // landing/utility components (e.g. data-platform, self-managed, search) would
190
+ // point the AI index at a 404.
191
+ componentsWithExports(components, pages).forEach(component => {
192
+ fullContent += ` - \`${siteUrl}/${component.name}-full.txt\`: ${component.title}\n`;
193
+ });
194
+ fullContent += `- **Individual markdown pages**: Each HTML page has a corresponding .md file (e.g., \`/docs/page.html\` → \`/docs/page.md\`)\n\n`;
195
+ fullContent += `### Accessing Versioned Content\n\n`;
196
+ fullContent += `For components with versioned documentation (like Redpanda Self-Managed), older versions can be accessed by replacing the version segment in the URL:\n`;
197
+ fullContent += `- Latest: \`${siteUrl}/current/page-path\`\n`;
198
+ fullContent += `- Specific version: \`${siteUrl}/24.3/page-path\`, \`${siteUrl}/25.1/page-path\`, etc.\n\n`;
199
+ fullContent += `Available versioned components: ${components.filter(c => c.versions.length > 1).map(c => c.name).join(', ')}\n\n`;
200
+ fullContent += `---\n\n`;
232
201
 
233
202
  // Sort pages by URL for consistent ordering
234
- componentPages.sort((a, b) => {
203
+ pages.sort((a, b) => {
235
204
  const urlA = a.pub?.url || '';
236
205
  const urlB = b.pub?.url || '';
237
206
  return urlA.localeCompare(urlB);
238
207
  });
239
208
 
240
- let componentContent = `# ${component.title} - Full Markdown Export\n\n`;
241
- componentContent += `> This file contains all ${component.title} documentation pages in markdown format for AI agent consumption.\n`;
242
- componentContent += `> Generated from ${componentPages.length} pages on ${new Date().toISOString()}\n`;
243
- componentContent += `> Component: ${component.name} | Version: ${latest.version}\n`;
244
- componentContent += `> Site: ${siteUrl}\n\n`;
245
- componentContent += `## About This Export\n\n`;
246
- componentContent += `This export includes the **latest version** (${latest.version}) of the ${component.title} documentation.\n\n`;
247
- componentContent += `### AI-Friendly Documentation Formats\n\n`;
248
- componentContent += `We provide multiple formats optimized for AI consumption:\n\n`;
249
- componentContent += `- **${siteUrl}/llms.txt**: Curated overview of all Redpanda documentation\n`;
250
- componentContent += `- **${siteUrl}/llms-full.txt**: Complete documentation export with all components\n`;
251
- componentContent += `- **${siteUrl}/${componentName}-full.txt**: This file - ${component.title} documentation only\n`;
252
- componentContent += `- **Individual markdown pages**: Each HTML page has a corresponding .md file\n\n`;
253
-
254
- if (component.versions.length > 1) {
255
- componentContent += `### Accessing Older Versions\n\n`;
256
- componentContent += `This component has versioned documentation. Older versions can be accessed by replacing the version segment in the URL:\n`;
257
- componentContent += `- Latest: \`${siteUrl}/current/page-path\`\n`;
258
- componentContent += `- Specific version: \`${siteUrl}/24.3/page-path\`, \`${siteUrl}/25.1/page-path\`, etc.\n\n`;
259
- }
260
-
261
- componentContent += `---\n\n`;
262
-
263
- // Add all pages
264
- componentPages.forEach((page, index) => {
209
+ pages.forEach((page, index) => {
265
210
  const mdUrl = page.pub?.url ? toMarkdownUrl(page.pub.url) : '';
266
211
  const pageUrl = mdUrl ? `${siteUrl}${mdUrl}` : 'unknown';
267
212
  const pageTitle = page.asciidoc?.doctitle || page.src?.stem || 'Untitled';
268
213
 
269
- componentContent += `# Page ${index + 1}: ${pageTitle}\n\n`;
270
- componentContent += `**URL**: ${pageUrl}\n\n`;
271
- componentContent += `---\n\n`;
214
+ fullContent += `# Page ${index + 1}: ${pageTitle}\n\n`;
215
+ fullContent += `**URL**: ${pageUrl}\n\n`;
216
+ fullContent += `---\n\n`;
272
217
  // Strip metadata (directive, source comments) from page content
273
- componentContent += stripMarkdownMetadata(page.markdownContents);
274
- componentContent += `\n\n---\n\n`;
218
+ fullContent += stripMarkdownMetadata(page.markdownContents);
219
+ fullContent += `\n\n---\n\n`;
275
220
  });
276
221
 
277
- // Add component-specific full.txt file to site root
222
+ // Add llms-full.txt to site root
278
223
  siteCatalog.addFile({
279
- contents: Buffer.from(componentContent, 'utf8'),
280
- out: { path: `${componentName}-full.txt` },
224
+ contents: Buffer.from(fullContent, 'utf8'),
225
+ out: { path: 'llms-full.txt' },
226
+ });
227
+ logger.info(`Generated llms-full.txt with ${pages.length} pages`);
228
+
229
+ // Generate component-specific full.txt files
230
+ logger.info('Generating component-specific full.txt files...');
231
+
232
+ // Group pages by component
233
+ pages.forEach(page => {
234
+ const componentName = page.src.component;
235
+ if (!componentGroups.has(componentName)) {
236
+ componentGroups.set(componentName, []);
237
+ }
238
+ componentGroups.get(componentName).push(page);
281
239
  });
282
- logger.info(`Generated ${componentName}-full.txt with ${componentPages.length} pages`);
240
+
241
+ // Generate a full.txt file for each component
242
+ componentGroups.forEach((componentPages, componentName) => {
243
+ const component = components.find(c => c.name === componentName);
244
+ if (!component) return;
245
+
246
+ const latest = component.latest || component.versions[0];
247
+ if (!latest) return;
248
+
249
+ // Sort pages by URL for consistent ordering
250
+ componentPages.sort((a, b) => {
251
+ const urlA = a.pub?.url || '';
252
+ const urlB = b.pub?.url || '';
253
+ return urlA.localeCompare(urlB);
254
+ });
255
+
256
+ let componentContent = `# ${component.title} - Full Markdown Export\n\n`;
257
+ componentContent += `> This file contains all ${component.title} documentation pages in markdown format for AI agent consumption.\n`;
258
+ componentContent += `> Generated from ${componentPages.length} pages on ${new Date().toISOString()}\n`;
259
+ componentContent += `> Component: ${component.name} | Version: ${latest.version}\n`;
260
+ componentContent += `> Site: ${siteUrl}\n\n`;
261
+ componentContent += `## About This Export\n\n`;
262
+ componentContent += `This export includes the **latest version** (${latest.version}) of the ${component.title} documentation.\n\n`;
263
+ componentContent += `### AI-Friendly Documentation Formats\n\n`;
264
+ componentContent += `We provide multiple formats optimized for AI consumption:\n\n`;
265
+ componentContent += `- **${siteUrl}/llms.txt**: Curated overview of all Redpanda documentation\n`;
266
+ componentContent += `- **${siteUrl}/llms-full.txt**: Complete documentation export with all components\n`;
267
+ componentContent += `- **${siteUrl}/${componentName}-full.txt**: This file - ${component.title} documentation only\n`;
268
+ componentContent += `- **Individual markdown pages**: Each HTML page has a corresponding .md file\n\n`;
269
+
270
+ if (component.versions.length > 1) {
271
+ componentContent += `### Accessing Older Versions\n\n`;
272
+ componentContent += `This component has versioned documentation. Older versions can be accessed by replacing the version segment in the URL:\n`;
273
+ componentContent += `- Latest: \`${siteUrl}/current/page-path\`\n`;
274
+ componentContent += `- Specific version: \`${siteUrl}/24.3/page-path\`, \`${siteUrl}/25.1/page-path\`, etc.\n\n`;
275
+ }
276
+
277
+ componentContent += `---\n\n`;
278
+
279
+ // Add all pages
280
+ componentPages.forEach((page, index) => {
281
+ const mdUrl = page.pub?.url ? toMarkdownUrl(page.pub.url) : '';
282
+ const pageUrl = mdUrl ? `${siteUrl}${mdUrl}` : 'unknown';
283
+ const pageTitle = page.asciidoc?.doctitle || page.src?.stem || 'Untitled';
284
+
285
+ componentContent += `# Page ${index + 1}: ${pageTitle}\n\n`;
286
+ componentContent += `**URL**: ${pageUrl}\n\n`;
287
+ componentContent += `---\n\n`;
288
+ // Strip metadata (directive, source comments) from page content
289
+ componentContent += stripMarkdownMetadata(page.markdownContents);
290
+ componentContent += `\n\n---\n\n`;
291
+ });
292
+
293
+ // Add component-specific full.txt file to site root
294
+ siteCatalog.addFile({
295
+ contents: Buffer.from(componentContent, 'utf8'),
296
+ out: { path: `${componentName}-full.txt` },
297
+ });
298
+ logger.info(`Generated ${componentName}-full.txt with ${componentPages.length} pages`);
299
+ });
300
+ }
301
+
302
+ // Generate page index files for every published version (not only the latest),
303
+ // so the llms.txt tree lists every page the sitemap lists.
304
+ const pageIndexes = buildPageIndexes({
305
+ pages: allPages,
306
+ components,
307
+ siteUrl,
308
+ toMarkdownUrl,
283
309
  });
310
+ pageIndexes.forEach((index) => {
311
+ siteCatalog.addFile({
312
+ contents: Buffer.from(index.contents, 'utf8'),
313
+ out: { path: index.path },
314
+ });
315
+ });
316
+ const indexedPageCount = pageIndexes.reduce((n, index) => n + index.pageCount, 0);
317
+ logger.info(`Generated ${pageIndexes.length} page index files covering ${indexedPageCount} pages`);
284
318
 
285
319
  // Add llms.txt to site root (using content extracted earlier)
286
320
  if (llmsPage && llmsPage.llmsTxtContent) {
@@ -297,6 +331,15 @@ module.exports.register = function () {
297
331
  llmsTxtContent = truncateAtNewline(llmsTxtContent, MAX_LLMS_TXT_CHARS - 100) + '\n\n[Content truncated due to size limits]';
298
332
  }
299
333
 
334
+ // The page index list is never truncated: an index missing from the root
335
+ // leaves its pages unreachable from llms.txt.
336
+ if (pageIndexes.length) {
337
+ llmsTxtContent = llmsTxtContent + '\n\n' + renderPageIndexSection(pageIndexes, components);
338
+ if (llmsTxtContent.length >= MAX_LLMS_TXT_CHARS) {
339
+ logger.warn(`llms.txt with page index list is ${llmsTxtContent.length} chars, over the ${MAX_LLMS_TXT_CHARS} char target. Shorten the llms page or raise the per-index size limit.`);
340
+ }
341
+ }
342
+
300
343
  // Generate navigation section with component sitemaps and key sections
301
344
  const navSection = generateNavigationSection(siteUrl, contentCatalog, components);
302
345
 
@@ -350,6 +393,10 @@ module.exports.register = function () {
350
393
  }
351
394
  });
352
395
 
396
+ pageIndexes.forEach((index) => {
397
+ if (index.lastModified) gitDates.set(index.path, index.lastModified);
398
+ });
399
+
353
400
  addToSitemap(contentCatalog, siteCatalog, siteUrl, gitDates, logger);
354
401
  } catch (err) {
355
402
  logger.warn(`Failed to add llms.txt to sitemap: ${err.message}`);
@@ -398,7 +445,8 @@ function addToSitemap(contentCatalog, siteCatalog, siteUrl, gitDates, logger) {
398
445
  const filename = file.out.path;
399
446
  return filename === 'llms.txt' ||
400
447
  filename === 'llms-full.txt' ||
401
- filename.endsWith('-full.txt');
448
+ filename.endsWith('-full.txt') ||
449
+ /(^|\/)llms(-\d+)?\.txt$/.test(filename);
402
450
  })
403
451
  .map(file => file.out.path)
404
452
  .sort(); // Sort for consistent ordering
@@ -379,7 +379,7 @@ Test categories:
379
379
  If a breaking change to the CLI is necessary:
380
380
 
381
381
  . Update the CLI with the breaking change
382
- . Increment the major version in package.json
382
+ . Mark the pull request as breaking with `!` after its type, for example `feat(cli)!: rename --tag to --ref`, so release-please cuts a major release
383
383
  . Update the MCP server to handle both old and new interfaces (if possible)
384
384
  . Update the CLI contract tests to verify the new interface
385
385
  . Update this document with the new contract
@@ -487,17 +487,13 @@ All CLI commands have:
487
487
  === Before releasing
488
488
 
489
489
  . Run all tests: `npm run test:mcp`
490
- . Update version in `package.json`
491
490
  . Update link:USER_GUIDE.adoc[USER_GUIDE.adoc] with any changes
492
491
  . Test manually with Claude Code
493
492
  . Check that setup command works: `npx doc-tools setup-mcp`
494
493
 
495
494
  === Publishing
496
495
 
497
- . Commit all changes
498
- . Tag the release: `git tag -a v1.2.3 -m "Release 1.2.3"`
499
- . Push with tags: `git push --tags`
500
- . Publish to npm: `npm publish`
496
+ Do not change the version, tag, or run `npm publish` by hand. Give your pull request a conventional-commit title (`feat(mcp): ...` or `fix(mcp): ...`). After it merges, release-please adds it to the open release pull request, and merging that pull request tags and publishes the release. See the Releases section of link:../CONTRIBUTING.adoc[CONTRIBUTING.adoc].
501
497
 
502
498
  === After releasing
503
499
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.50.0",
3
+ "version": "5.51.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -2848,14 +2848,20 @@ async function generateRpkDocs(options = {}) {
2848
2848
  // Build subcommands with correct xref paths
2849
2849
  // Filter out excluded and asPartial subcommands — excluded have no file,
2850
2850
  // asPartial ones live in the partials directory with no linkable xref.
2851
+ // The exception is a parent that is itself asPartial: its partial is
2852
+ // included by a stub in the component that publishes the whole subtree
2853
+ // (rpk sql in cloud-docs, rpk ai in adp-docs), so relative xrefs to its
2854
+ // children resolve there, the same way rpk cloud partials link.
2851
2855
  // rpk cloud and rpk security secret rows stay in the table: their pages
2852
2856
  // are routed to the cloud partials directory and published by
2853
2857
  // cloud-docs, so the xref below links across to the cloud component
2854
2858
  // instead of dropping the row (which hid the subcommand entirely).
2859
+ const parentIsPartial = shouldUsePartialDir(resolvedOverrides, commandPath)
2855
2860
  const subcommands = (command.commands || [])
2856
2861
  .filter(sub => {
2857
2862
  const subPath = `${commandPath} ${sub.name}`
2858
- return !shouldExcludeCommand(resolvedOverrides, subPath) && !shouldUsePartialDir(resolvedOverrides, subPath)
2863
+ return !shouldExcludeCommand(resolvedOverrides, subPath) &&
2864
+ (parentIsPartial || !shouldUsePartialDir(resolvedOverrides, subPath))
2859
2865
  })
2860
2866
  .map(sub => {
2861
2867
  const subPath = `${commandPath} ${sub.name}`
@@ -1137,15 +1137,21 @@ const WHATS_NEW_EXCLUDED_SUBTREES = ['rpk ai']
1137
1137
 
1138
1138
  /**
1139
1139
  * Return a copy of diffData without entries under the excluded subtrees.
1140
+ * Commands marked asPartial in the overrides are dropped too: they render
1141
+ * only as partials single-sourced into another component (for example,
1142
+ * rpk sql for Redpanda SQL in Cloud), so they are not Self-Managed changes.
1140
1143
  * Only the published What's-new block filters; diff reports and PR
1141
1144
  * summaries keep the full picture.
1142
1145
  * @param {Object} diffData - Diff from generateRpkDiff
1143
1146
  * @param {string[]} [excluded] - Command-path prefixes to drop
1147
+ * @param {Object} [overridesData] - rpk overrides; asPartial subtrees are dropped
1144
1148
  * @returns {Object} Filtered copy
1145
1149
  */
1146
- function filterDiffForWhatsNew(diffData, excluded = WHATS_NEW_EXCLUDED_SUBTREES) {
1150
+ function filterDiffForWhatsNew(diffData, excluded = WHATS_NEW_EXCLUDED_SUBTREES, overridesData = null) {
1151
+ const resolved = overridesData ? resolveReferences(overridesData, overridesData) : null
1147
1152
  const outside = (cmdPath) => !excluded.some(prefix =>
1148
- cmdPath === prefix || (typeof cmdPath === 'string' && cmdPath.startsWith(prefix + ' ')))
1153
+ cmdPath === prefix || (typeof cmdPath === 'string' && cmdPath.startsWith(prefix + ' '))) &&
1154
+ !(resolved && typeof cmdPath === 'string' && shouldUsePartialDir(resolved, cmdPath))
1149
1155
  const details = diffData.details || {}
1150
1156
  const filteredDetails = { ...details }
1151
1157
  for (const key of ['newCommands', 'newlyDeprecatedCommands', 'removedCommands', 'descriptionChanges']) {
@@ -1167,7 +1173,28 @@ function updateWhatsNewFile(diffData, whatsNewPath, version, options = {}) {
1167
1173
  ...options
1168
1174
  })
1169
1175
 
1176
+ const startMarker = `// AUTOGEN-RPK-CHANGES ${version} START`
1177
+ const endMarker = `// AUTOGEN-RPK-CHANGES ${version} END`
1178
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1179
+
1170
1180
  if (!whatsNewContent) {
1181
+ // A re-run for a version that now has nothing to publish (for example,
1182
+ // every change was filtered out as asPartial) must remove that version's
1183
+ // earlier block, or the page keeps publishing what the filter excluded.
1184
+ if (fs.existsSync(whatsNewPath)) {
1185
+ const existing = fs.readFileSync(whatsNewPath, 'utf8')
1186
+ if (existing.includes(startMarker)) {
1187
+ const blockRe = new RegExp(`\\n*${escapeRe(startMarker)}[\\s\\S]*?${escapeRe(endMarker)}\\n*`)
1188
+ let updated = existing.replace(blockRe, '\n\n')
1189
+ // Drop the section heading too if the block was all it held, so no
1190
+ // empty heading is left on the page.
1191
+ const emptySectionRe = new RegExp(`^${escapeRe(sectionHeading)}[^\\n]*\\n\\s*(?=^== |(?![\\s\\S]))`, 'm')
1192
+ updated = updated.replace(emptySectionRe, '')
1193
+ fs.writeFileSync(whatsNewPath, updated.replace(/\s*$/, '\n'), 'utf8')
1194
+ console.log(`Removed ${version} block from what's-new file (no changes left to publish): ${whatsNewPath}`)
1195
+ return
1196
+ }
1197
+ }
1171
1198
  console.log('No Redpanda CLI changes to add to what\'s new')
1172
1199
  return
1173
1200
  }
@@ -1187,11 +1214,8 @@ function updateWhatsNewFile(diffData, whatsNewPath, version, options = {}) {
1187
1214
  // Redpanda CLI section instead of being dropped. Writers can edit or
1188
1215
  // remove blocks freely; the automation only ever touches content between
1189
1216
  // its own markers for the same version label.
1190
- const startMarker = `// AUTOGEN-RPK-CHANGES ${version} START`
1191
- const endMarker = `// AUTOGEN-RPK-CHANGES ${version} END`
1192
1217
  const sectionBody = whatsNewContent.replace(/^== [^\n]*\n+/, '')
1193
1218
  const block = `${startMarker}\n${sectionBody.trimEnd()}\n${endMarker}`
1194
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1195
1219
 
1196
1220
  if (existingContent.includes(startMarker)) {
1197
1221
  // Replace this version's existing block (idempotent re-runs)
@@ -2145,7 +2169,7 @@ async function handleRpkDocsGeneration(options = {}) {
2145
2169
 
2146
2170
  // Update what's-new file if requested
2147
2171
  if (whatsNewPath) {
2148
- updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2172
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData, WHATS_NEW_EXCLUDED_SUBTREES, overridesData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2149
2173
  }
2150
2174
  } else {
2151
2175
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)
@@ -2467,7 +2491,7 @@ async function handleRpkDocsGeneration(options = {}) {
2467
2491
 
2468
2492
  // Update what's-new file if requested
2469
2493
  if (whatsNewPath) {
2470
- updateWhatsNewFile(filterDiffForWhatsNew(diffData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2494
+ updateWhatsNewFile(filterDiffForWhatsNew(diffData, WHATS_NEW_EXCLUDED_SUBTREES, overridesData), whatsNewPath, rpkVersion, { linkable: makeLinkablePredicate(overridesData), hasSubcommands: makeSubcommandPredicate(tree) })
2471
2495
  }
2472
2496
  } else {
2473
2497
  console.warn(`Warning: Could not load previous version ${diffVersion} for diff`)