@redpanda-data/docs-extensions-and-macros 4.14.1 → 4.15.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.
package/bin/doc-tools.js CHANGED
@@ -783,7 +783,7 @@ automation
783
783
  )
784
784
  .option('-t, --tag <tag>', 'Git tag for released content (GA/beta)')
785
785
  .option('-b, --branch <branch>', 'Branch name for in-progress content')
786
- .option('--diff <oldTag>', 'Also diff autogenerated properties from <oldTag> to current tag/branch')
786
+ .option('--diff <oldTag>', 'Diff properties against <oldTag> and restore removed deprecated properties. Recommended for accurate output; falls back to latest-redpanda-tag from antora.yml if not specified')
787
787
  .option('--overrides <path>', 'Optional JSON file with property description overrides', 'docs-data/property-overrides.json')
788
788
  .option('--output-dir <dir>', 'Where to write all generated files', 'modules/reference')
789
789
  .option('--cloud-support', 'Add AsciiDoc tags to generated property docs to indicate which ones are supported in Redpanda Cloud. This data is fetched from the cloudv2 repository so requires a GitHub token with repo permissions. Set the token as an environment variable using GITHUB_TOKEN, GH_TOKEN, or REDPANDA_GITHUB_TOKEN', true)
@@ -826,11 +826,17 @@ automation
826
826
  }
827
827
  }
828
828
 
829
+ if (!oldTag) {
830
+ console.warn('Warning: No previous version specified (--diff) and no latest-redpanda-tag found in Antora attributes.')
831
+ console.warn(' Deprecated properties that were removed from source (v26.1+) will not be detected.')
832
+ console.warn(' For accurate output, specify --diff <previous-tag> or set latest-redpanda-tag in antora.yml.')
833
+ }
834
+
829
835
  const overridesPath = options.overrides
830
836
  const outputDir = options.outputDir
831
837
  const cwd = path.resolve(__dirname, '../tools/property-extractor')
832
838
 
833
- const make = (tag, overrides, templates = {}, outDir = 'modules/reference/') => {
839
+ const make = (tag, overrides, templates = {}, outDir = 'modules/reference/', { skipPartials = false } = {}) => {
834
840
  console.log(`Building property docs for ${tag}…`)
835
841
  const args = ['build', `TAG=${tag}`]
836
842
  const env = { ...process.env }
@@ -843,7 +849,7 @@ automation
843
849
  if (templates.deprecatedProperty) env.TEMPLATE_DEPRECATED_PROPERTY = path.resolve(templates.deprecatedProperty)
844
850
  env.OUTPUT_JSON_DIR = path.resolve(outDir, 'attachments')
845
851
  env.OUTPUT_AUTOGENERATED_DIR = path.resolve(outDir)
846
- if (options.generatePartials) {
852
+ if (options.generatePartials && !skipPartials) {
847
853
  env.GENERATE_PARTIALS = '1'
848
854
  env.OUTPUT_PARTIALS_DIR = path.resolve(outDir, options.partialsDir || 'partials')
849
855
  }
@@ -864,11 +870,21 @@ automation
864
870
  }
865
871
 
866
872
  const tagsAreSame = oldTag && newTag && oldTag === newTag
867
- if (oldTag && !tagsAreSame) {
868
- make(oldTag, overridesPath, templates, outputDir)
873
+ const needsDiff = oldTag && !tagsAreSame
874
+
875
+ // Phase 1: Extract JSON from C++ source.
876
+ // When a diff is needed, skip AsciiDoc generation during extraction so we
877
+ // can merge removed deprecated properties first and generate only once.
878
+ if (needsDiff) {
879
+ make(oldTag, overridesPath, templates, outputDir, { skipPartials: true })
880
+ make(newTag, overridesPath, templates, outputDir, { skipPartials: true })
881
+ } else {
882
+ make(newTag, overridesPath, templates, outputDir)
869
883
  }
870
- make(newTag, overridesPath, templates, outputDir)
871
- if (oldTag && !tagsAreSame) {
884
+
885
+ // Phase 2: Compare old vs new and merge removed deprecated properties into
886
+ // the new JSON so they appear in the generated documentation.
887
+ if (needsDiff) {
872
888
  const diffOutputDir = overridesPath ? path.dirname(path.resolve(overridesPath)) : outputDir
873
889
  generatePropertyComparisonReport(oldTag, newTag, diffOutputDir)
874
890
 
@@ -888,6 +904,18 @@ automation
888
904
  }
889
905
 
890
906
  cleanupOldDiffs(diffOutputDir)
907
+
908
+ // Phase 3: Generate AsciiDoc once from the complete JSON (includes merged deprecated properties)
909
+ if (options.generatePartials) {
910
+ const updatedJsonPath = path.resolve(outputDir, 'attachments', `redpanda-properties-${newTag}.json`)
911
+ if (fs.existsSync(updatedJsonPath)) {
912
+ process.env.GENERATE_PARTIALS = '1'
913
+ process.env.OUTPUT_PARTIALS_DIR = path.resolve(outputDir, options.partialsDir || 'partials')
914
+ const { generateAllDocs } = require('../tools/property-extractor/generate-handlebars-docs')
915
+ console.log('Generating AsciiDoc from complete property data…')
916
+ generateAllDocs(updatedJsonPath, path.resolve(outputDir))
917
+ }
918
+ }
891
919
  }
892
920
 
893
921
  if (!options.diff && !tagsAreSame) {
@@ -0,0 +1,190 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Extracts markdown from llms.adoc page and generates llms.txt and llms-full.txt.
5
+ *
6
+ * This extension:
7
+ * 1. Adds site-url attribute to home component:
8
+ * - In preview builds (PREVIEW=true): Uses DEPLOY_PRIME_URL
9
+ * - In production builds: Uses playbook.site.url
10
+ * 2. Finds llms page in home component (after AsciiDoc processing)
11
+ * 3. Gets the markdown content from page.markdownContents (set by convert-to-markdown extension)
12
+ * 4. Unpublishes the HTML page
13
+ * 5. Places llms.txt (markdown) at site root
14
+ * 6. Generates llms-full.txt with markdown from latest versions
15
+ *
16
+ * Must run after convert-to-markdown extension to access page.markdownContents.
17
+ */
18
+ module.exports.register = function () {
19
+ const logger = this.getLogger('convert-llms-to-txt-extension');
20
+ let siteUrl = '';
21
+
22
+ // Add site-url attribute to home component
23
+ this.on('playbookBuilt', ({ playbook }) => {
24
+ // In preview builds, always use the deploy preview URL
25
+ if (process.env.PREVIEW === 'true' && process.env.DEPLOY_PRIME_URL) {
26
+ siteUrl = process.env.DEPLOY_PRIME_URL;
27
+ logger.info(`Using deploy preview URL: ${siteUrl}`);
28
+ } else {
29
+ siteUrl = playbook.site?.url || 'https://docs.redpanda.com';
30
+ logger.info(`Using site URL: ${siteUrl}`);
31
+ }
32
+ });
33
+
34
+ this.on('contentClassified', ({ contentCatalog }) => {
35
+ // Add site-url attribute to home component
36
+ const homeComponent = contentCatalog.getComponents().find(c => c.name === 'home');
37
+ if (homeComponent && homeComponent.versions) {
38
+ homeComponent.versions.forEach(version => {
39
+ if (!version.asciidoc) version.asciidoc = {};
40
+ if (!version.asciidoc.attributes) version.asciidoc.attributes = {};
41
+ version.asciidoc.attributes['site-url'] = siteUrl;
42
+ logger.debug(`Added site-url attribute to home component: ${siteUrl}`);
43
+ });
44
+ }
45
+ });
46
+
47
+ // Run after pagesComposed so convert-to-markdown has already run
48
+ this.on('beforePublish', ({ contentCatalog, siteCatalog }) => {
49
+ // Find llms.adoc page in home component (after markdown conversion)
50
+ const llmsPage = contentCatalog.findBy({
51
+ component: 'home',
52
+ family: 'page',
53
+ }).find(page => page.src.stem === 'llms');
54
+
55
+ if (!llmsPage) {
56
+ logger.warn('No llms page found, skipping llms.txt generation');
57
+ } else {
58
+ logger.info(`Found llms page: ${llmsPage.src.path}`);
59
+ logger.info(`Has markdownContents: ${!!llmsPage.markdownContents}`);
60
+ logger.info(`Has out: ${!!llmsPage.out}`);
61
+ try {
62
+ // The convert-to-markdown extension has already processed this page
63
+ // and stored the markdown in page.markdownContents
64
+ if (!llmsPage.markdownContents) {
65
+ throw new Error('No markdown content found on llms page. Ensure convert-to-markdown extension runs before this extension.');
66
+ }
67
+
68
+ let content = llmsPage.markdownContents.toString('utf8');
69
+ logger.info(`Extracted ${content.length} bytes of markdown content`);
70
+
71
+ // Strip HTML comments added by convert-to-markdown extension
72
+ // These reference the unpublished /home/llms/ URL which doesn't make sense for llms.txt
73
+ content = content.replace(/^<!--[\s\S]*?-->\s*/gm, '').trim();
74
+ logger.debug(`Stripped HTML comments, now ${content.length} bytes`);
75
+
76
+ // Fix URLs: convert em dashes back to double hyphens
77
+ // The markdown converter applies smart typography that turns -- into — (em dash)
78
+ // This breaks URLs like deploy-preview-159--redpanda-documentation.netlify.app
79
+ content = content.replace(/\(https?:\/\/[^)]*—[^)]*\)/g, (match) => {
80
+ return match.replace(/—/g, '--');
81
+ });
82
+ logger.debug('Fixed em dashes in URLs');
83
+
84
+ // Unpublish the HTML page FIRST (following unpublish-pages pattern)
85
+ if (llmsPage.out) {
86
+ if (!siteCatalog.unpublishedPages) siteCatalog.unpublishedPages = [];
87
+ if (llmsPage.pub?.url) {
88
+ siteCatalog.unpublishedPages.push(llmsPage.pub.url);
89
+ }
90
+ delete llmsPage.out;
91
+ logger.info('Unpublished llms HTML page');
92
+ }
93
+
94
+ // Store cleaned markdown content for adding after llms-full.txt
95
+ llmsPage.llmsTxtContent = content;
96
+
97
+ } catch (err) {
98
+ logger.error(`Failed to extract markdown from llms page: ${err.message}`);
99
+ logger.debug(err.stack);
100
+ }
101
+ }
102
+
103
+ // Generate llms-full.txt - aggregate markdown from latest version of each component
104
+ logger.info('Generating llms-full.txt from latest version pages with markdown...');
105
+
106
+ // Get all components and identify latest versions
107
+ const components = contentCatalog.getComponents();
108
+ const latestVersions = new Set();
109
+
110
+ components.forEach(component => {
111
+ // Find the latest version (non-prerelease if available, otherwise the first version)
112
+ const latest = component.latest || component.versions[0];
113
+ if (latest) {
114
+ latestVersions.add(`${component.name}@${latest.version}`);
115
+ logger.debug(`Latest version for ${component.name}: ${latest.version}`);
116
+ }
117
+ });
118
+
119
+ // Filter pages to only include latest versions
120
+ const allPages = contentCatalog.getPages((p) => p.markdownContents);
121
+ const pages = allPages.filter(page => {
122
+ const pageKey = `${page.src.component}@${page.src.version}`;
123
+ return latestVersions.has(pageKey);
124
+ });
125
+
126
+ if (!pages.length) {
127
+ logger.warn('No pages with markdown content found in latest versions, skipping llms-full.txt generation');
128
+ return;
129
+ }
130
+
131
+ logger.info(`Filtered to ${pages.length} pages from ${latestVersions.size} latest component versions (from ${allPages.length} total pages)`);
132
+
133
+ let fullContent = `# Redpanda Documentation - Full Markdown Export\n\n`;
134
+ fullContent += `> This file contains all documentation pages in markdown format for AI agent consumption.\n`;
135
+ fullContent += `> Generated from ${pages.length} pages on ${new Date().toISOString()}\n`;
136
+ fullContent += `> Site: ${siteUrl}\n\n`;
137
+ fullContent += `## About This Export\n\n`;
138
+ fullContent += `This export includes only the **latest version** of each component's documentation:\n`;
139
+ components.forEach(component => {
140
+ const latest = component.latest || component.versions[0];
141
+ if (latest) {
142
+ fullContent += `- **${component.title}**: version ${latest.version}\n`;
143
+ }
144
+ });
145
+ fullContent += `\n`;
146
+ fullContent += `### Accessing Versioned Content\n\n`;
147
+ fullContent += `For components with versioned documentation (like Redpanda Self-Managed), older versions can be accessed by replacing the version segment in the URL:\n`;
148
+ fullContent += `- Latest: \`${siteUrl}/current/page-path\`\n`;
149
+ fullContent += `- Specific version: \`${siteUrl}/24.3/page-path\`, \`${siteUrl}/25.1/page-path\`, etc.\n\n`;
150
+ fullContent += `Available versioned components: ${components.filter(c => c.versions.length > 1).map(c => c.name).join(', ')}\n\n`;
151
+ fullContent += `---\n\n`;
152
+
153
+ // Sort pages by URL for consistent ordering
154
+ pages.sort((a, b) => {
155
+ const urlA = a.pub?.url || '';
156
+ const urlB = b.pub?.url || '';
157
+ return urlA.localeCompare(urlB);
158
+ });
159
+
160
+ pages.forEach((page, index) => {
161
+ const pageUrl = page.pub?.url ? `${siteUrl}${page.pub.url}` : 'unknown';
162
+ const pageTitle = page.asciidoc?.doctitle || page.src?.stem || 'Untitled';
163
+
164
+ fullContent += `# Page ${index + 1}: ${pageTitle}\n\n`;
165
+ fullContent += `**URL**: ${pageUrl}\n\n`;
166
+ fullContent += `---\n\n`;
167
+ fullContent += page.markdownContents.toString('utf8');
168
+ fullContent += `\n\n---\n\n`;
169
+ });
170
+
171
+ // Add llms-full.txt to site root
172
+ siteCatalog.addFile({
173
+ contents: Buffer.from(fullContent, 'utf8'),
174
+ out: { path: 'llms-full.txt' },
175
+ });
176
+ logger.info(`Generated llms-full.txt with ${pages.length} pages`);
177
+
178
+ // Add llms.txt to site root (using content extracted earlier)
179
+ if (llmsPage && llmsPage.llmsTxtContent) {
180
+ logger.info('Adding llms.txt to site root');
181
+ siteCatalog.addFile({
182
+ contents: Buffer.from(llmsPage.llmsTxtContent, 'utf8'),
183
+ out: { path: 'llms.txt' },
184
+ });
185
+ logger.info('Successfully added llms.txt');
186
+ } else {
187
+ logger.warn('llms.txt not generated - page not found or no content extracted');
188
+ }
189
+ });
190
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "4.14.1",
3
+ "version": "4.15.1",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -65,6 +65,7 @@
65
65
  "./extensions/find-related-docs": "./extensions/find-related-docs.js",
66
66
  "./extensions/unpublish-pages": "./extensions/unpublish-pages.js",
67
67
  "./extensions/find-related-labs": "./extensions/find-related-labs.js",
68
+ "./extensions/convert-llms-to-txt": "./extensions/convert-llms-to-txt.js",
68
69
  "./extensions/modify-redirects": "./extensions/produce-redirects.js",
69
70
  "./extensions/algolia-indexer/index": "./extensions/algolia-indexer/index.js",
70
71
  "./extensions/aggregate-terms": "./extensions/aggregate-terms.js",
@@ -86,7 +86,11 @@ redpanda-git:
86
86
  git clone -q https://github.com/redpanda-data/redpanda.git "$(REDPANDA_SRC)"; \
87
87
  fi; \
88
88
  fi; \
89
- if git -C "$(REDPANDA_SRC)" rev-parse --verify -q "$(TAG)" >/dev/null; then \
89
+ if git -C "$(REDPANDA_SRC)" rev-parse --verify -q "origin/$(TAG)" >/dev/null 2>&1; then \
90
+ echo "🔖 Checking out remote branch 'origin/$(TAG)'"; \
91
+ git -C "$(REDPANDA_SRC)" checkout -q "$(TAG)" 2>/dev/null || git -C "$(REDPANDA_SRC)" checkout -q -b "$(TAG)" "origin/$(TAG)"; \
92
+ git -C "$(REDPANDA_SRC)" reset --hard "origin/$(TAG)" -q; \
93
+ elif git -C "$(REDPANDA_SRC)" rev-parse --verify -q "$(TAG)" >/dev/null 2>&1; then \
90
94
  echo "🔖 Checking out '$(TAG)'"; \
91
95
  git -C "$(REDPANDA_SRC)" checkout -q "$(TAG)"; \
92
96
  else \
@@ -9,12 +9,46 @@
9
9
  * - Properties with changed descriptions
10
10
  * - Properties with changed types
11
11
  * - Deprecated properties
12
+ * - Removed deprecated properties (deprecated in old version, deleted from source in new)
12
13
  * - Removed properties
13
14
  * - Properties with empty descriptions (excluding deprecated)
14
15
  */
15
16
 
16
17
  const fs = require('fs');
17
18
  const path = require('path');
19
+ const semver = require('semver');
20
+
21
+ /**
22
+ * The minimum version where Redpanda removes deprecated properties from the
23
+ * C++ source code instead of keeping them tagged as deprecated_property.
24
+ */
25
+ const DEPRECATED_REMOVAL_MIN_VERSION = '26.1.0';
26
+
27
+ /**
28
+ * Check if the new version deletes deprecated properties from source.
29
+ *
30
+ * Before v26.1, deprecated properties were kept in the C++ source tagged as
31
+ * deprecated_property. Starting from v26.1, they are deleted entirely. When
32
+ * a property exists in the old version but is absent in the new version, and
33
+ * the new version is >= v26.1, we treat it as a deprecation (the property was
34
+ * removed because it was deprecated).
35
+ *
36
+ * Returns false only when the new version is explicitly below v26.1.
37
+ * For unparseable versions (e.g. "dev", "main"), assumes the latest behavior.
38
+ *
39
+ * @param {string} newVersion - The new version string.
40
+ * @returns {boolean} True if the new version deletes deprecated properties.
41
+ */
42
+ function isCrossDeprecatedRemovalBoundary(newVersion) {
43
+ // Only skip if the new version is explicitly below the boundary
44
+ const newCleaned = semver.coerce(newVersion);
45
+ if (newCleaned && semver.lt(newCleaned, DEPRECATED_REMOVAL_MIN_VERSION)) {
46
+ return false;
47
+ }
48
+ // New version is either >= 26.1 or unparseable (e.g. "dev", "main").
49
+ // In both cases, assume the new version has the removal behavior.
50
+ return true;
51
+ }
18
52
 
19
53
  /**
20
54
  * Recursively compares two values for structural deep equality.
@@ -134,12 +168,15 @@ function compareProperties(oldData, newData, oldVersion, newVersion) {
134
168
  const oldProps = extractProperties(oldData);
135
169
  const newProps = extractProperties(newData);
136
170
 
171
+ const removesDeprecated = isCrossDeprecatedRemovalBoundary(newVersion);
172
+
137
173
  const report = {
138
174
  newProperties: [],
139
175
  changedDefaults: [],
140
176
  changedDescriptions: [],
141
177
  changedTypes: [],
142
178
  deprecatedProperties: [],
179
+ removedDeprecatedProperties: [],
143
180
  removedProperties: [],
144
181
  emptyDescriptions: []
145
182
  };
@@ -206,11 +243,22 @@ function compareProperties(oldData, newData, oldVersion, newVersion) {
206
243
  // Check both the is_experimental_property field and development_ prefix
207
244
  const isExperimental = oldProp.is_experimental_property || name.startsWith('development_');
208
245
  if (!isExperimental) {
209
- report.removedProperties.push({
210
- name,
211
- type: oldProp.type,
212
- description: oldProp.description || 'No description'
213
- });
246
+ // In v26.1+, deprecated properties are deleted from source instead of
247
+ // being tagged. If a property existed before and is gone now, it was
248
+ // deprecated.
249
+ if (removesDeprecated) {
250
+ report.removedDeprecatedProperties.push({
251
+ name,
252
+ type: oldProp.type,
253
+ description: oldProp.description || 'No description'
254
+ });
255
+ } else {
256
+ report.removedProperties.push({
257
+ name,
258
+ type: oldProp.type,
259
+ description: oldProp.description || 'No description'
260
+ });
261
+ }
214
262
  }
215
263
  }
216
264
  }
@@ -288,6 +336,14 @@ function generateConsoleReport(report, oldVersion, newVersion) {
288
336
  });
289
337
  }
290
338
 
339
+ if (report.removedDeprecatedProperties.length > 0) {
340
+ console.log(`\n➤ Removed deprecated properties (${report.removedDeprecatedProperties.length}):`);
341
+ console.log(` These were deprecated in ${oldVersion} and removed from source in ${newVersion}.`);
342
+ report.removedDeprecatedProperties.forEach(prop => {
343
+ console.log(` • ${prop.name} (${prop.type})`);
344
+ });
345
+ }
346
+
291
347
  if (report.removedProperties.length > 0) {
292
348
  console.log(`\n➤ Removed properties (${report.removedProperties.length}):`);
293
349
  report.removedProperties.forEach(prop => {
@@ -329,6 +385,7 @@ function generateJsonReport(report, oldVersion, newVersion, outputPath) {
329
385
  changedDescriptions: report.changedDescriptions.length,
330
386
  changedTypes: report.changedTypes.length,
331
387
  deprecatedProperties: report.deprecatedProperties.length,
388
+ removedDeprecatedProperties: report.removedDeprecatedProperties.length,
332
389
  removedProperties: report.removedProperties.length,
333
390
  emptyDescriptions: report.emptyDescriptions.length
334
391
  },
@@ -370,17 +427,39 @@ function comparePropertyFiles(oldFilePath, newFilePath, oldVersion, newVersion,
370
427
  const newData = JSON.parse(fs.readFileSync(newFilePath, 'utf8'));
371
428
 
372
429
  const report = compareProperties(oldData, newData, oldVersion, newVersion);
373
-
430
+
431
+ // Merge removed deprecated properties back into the new JSON so they
432
+ // remain in generated documentation with is_deprecated + removed_deprecated flags
433
+ if (report.removedDeprecatedProperties.length > 0) {
434
+ const oldProps = extractProperties(oldData);
435
+ const newProps = extractProperties(newData);
436
+
437
+ report.removedDeprecatedProperties.forEach(({ name }) => {
438
+ if (oldProps[name] && !newProps[name]) {
439
+ newProps[name] = {
440
+ ...oldProps[name],
441
+ is_deprecated: true,
442
+ removed_deprecated: true,
443
+ visibility: 'deprecated'
444
+ };
445
+ }
446
+ });
447
+
448
+ newData.properties = newProps;
449
+ fs.writeFileSync(newFilePath, JSON.stringify(newData, null, 2), 'utf8');
450
+ console.log(`\nMerged ${report.removedDeprecatedProperties.length} removed deprecated properties back into ${newVersion} JSON`);
451
+ }
452
+
374
453
  // Generate console report
375
454
  generateConsoleReport(report, oldVersion, newVersion);
376
-
455
+
377
456
  // Generate JSON report if output directory provided
378
457
  if (outputDir) {
379
458
  fs.mkdirSync(outputDir, { recursive: true });
380
459
  const jsonReportPath = path.join(outputDir, filename);
381
460
  generateJsonReport(report, oldVersion, newVersion, jsonReportPath);
382
461
  }
383
-
462
+
384
463
  return report;
385
464
  } catch (error) {
386
465
  console.error(`Error: Error comparing properties: ${error.message}`);