@redpanda-data/docs-extensions-and-macros 3.11.2 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.adoc CHANGED
@@ -165,6 +165,71 @@ antora:
165
165
  index-latest-only: true
166
166
  ```
167
167
 
168
+ === Archive attachments
169
+
170
+ The `archive-attachments` extension automates the packaging of specific attachment files into a compressed archive (`.tar.gz`) based on configurable patterns. This archive is then made available to the generated site, allowing users to easily download grouped resources such as Docker Compose configurations.
171
+
172
+ This extension enables you to define which files and directories to include in the archive, ensuring that only relevant content is packaged and accessible.
173
+
174
+ ==== Environment variables
175
+
176
+ This extension does not require any environment variables.
177
+
178
+ ==== Configuration options
179
+
180
+ The extension accepts the following options in the Antora playbook.
181
+
182
+ Configure the extension in your Antora playbook by defining an array of archive configurations under `data.archives`. Each archive configuration includes:
183
+
184
+ output_archive (string, required):: The name of the generated archive file.
185
+
186
+ component (string, required):: The name of the Antora component whose attachments should be archived.
187
+
188
+ file_patterns (array of strings, required):: Glob patterns specifying which attachment paths to include in the archive.
189
+
190
+ NOTE: Ensure that `file_patterns` accurately reflect the paths of the attachments you want to archive. Overly broad patterns may include unintended files, while overly restrictive patterns might exclude necessary resources.
191
+
192
+ ==== Example configuration
193
+
194
+ Here's an example configuration to enable the extension:
195
+
196
+ ```yaml
197
+ antora:
198
+ extensions:
199
+ - require: '../docs-extensions-and-macros/extensions/archive-creation-extension.js'
200
+ data:
201
+ archives:
202
+ - output_archive: 'redpanda-quickstart.tar.gz' <1>
203
+ component: 'ROOT' <2>
204
+ file_patterns:
205
+ - '**/test-resources/**/docker-compose/**' <3>
206
+ ```
207
+
208
+ <1> Defines the name of the generated archive placed at the site root.
209
+ <2> Defines the name of the component in which to search for attachments.
210
+ <3> Lists the glob patterns to match attachment paths for inclusion in the archive.
211
+ +
212
+ - `**`: Matches any number of directories.
213
+ - `/test-resources/`: Specifies that the matching should occur within the `test-resources/` directory.
214
+ - `/docker-compose/`: Targets the `docker-compose/` directory and all its subdirectories.
215
+ - `**:` Ensures that all files and nested directories within `docker-compose/` are included.
216
+
217
+ === Behavior with multiple components/versions
218
+
219
+ *Scenario*: Multiple components and/or multiple versions of the same component contain attachments that match the defined file_patterns.
220
+
221
+ *Outcome*: Separate archives for each component version.
222
+
223
+ For each matching (component, version) pair, the extension creates a distinct archive named `<version>-<output_archive>`. For example:
224
+ `24.3-redpanda-quickstart.tar.gz`.
225
+
226
+ These archives are placed at the site root, ensuring they are easily accessible and do not overwrite each other.
227
+
228
+ For the latest version of each component, the extension also adds the archive using the base `output_archive` name. As a result, the latest archives are accessible through a consistent filename, facilitating easy downloads without needing to reference version numbers.
229
+
230
+ Because each archive has a unique filename based on the component version, there is no risk of archives overwriting each other.
231
+ The only exception is the archive for the latest version, which consistently uses the `output_archive` name.
232
+
168
233
  === Component category aggregator
169
234
 
170
235
  This extension maps Redpanda Connect component data into a structured format:
@@ -189,6 +254,90 @@ antora:
189
254
  - require: '@redpanda-data/docs-extensions-and-macros/extensions/generate-rp-connect-categories'
190
255
  ```
191
256
 
257
+ === Compute end-of-life extension
258
+
259
+ This extension calculates and attaches metadata related to the end-of-life (EoL) status of docs pages, such as nearing EoL, past EoL, and associated EoL dates. This metadata can be used to display relevant banners or messages in docs to inform users about the lifecycle of each version.
260
+
261
+ The extension leverages configuration settings provided in the Antora playbook to apply EoL calculations, specify the warning period, and include links to upgrade documentation and EoL policies.
262
+
263
+ The extension computes whether a page is nearing EoL or past EoL based on the `page-release-date` attribute and configured settings.
264
+ It injects the following attributes into each page, making them available for use in UI templates:
265
+
266
+ - `page-is-nearing-eol`: Indicates if the page is within the warning period before EoL. Calculated using `(page-release-date + supported_months) - warning_weeks`.
267
+ - `page-is-past-eol`: Indicates if the page has passed its EoL. Calculated using `today > (page-release-date + supported_months)`.
268
+ - `page-eol-date`: The calculated EoL date in a human-readable format. Calculated using `page-release-date + supported_months`.
269
+ - `page-eol-doc`: The URL to the supported versions policy or EoL documentation.
270
+ - `page-upgrade-doc`: The Antora resource ID to a document containing upgrade instructions.
271
+
272
+ ==== Environment variables
273
+
274
+ This extension does not require any environment variables.
275
+
276
+ ==== Configuration options
277
+
278
+ To enable and configure the extension, add it to the `antora.extensions` section of your Antora playbook. Define the EoL settings under the `data.eol_settings` key with the following options:
279
+
280
+ `component` (required):: The component name to which the configuration applies.
281
+ `eol_doc` (required):: A link to the supported versions policy or EoL documentation.
282
+ `upgrade_doc` (required):: A link to the upgrade instructions.
283
+ `supported_months` (optional, default: 12):: The number of months after the publish date when the documentation reaches its EoL.
284
+ `warning_weeks` (optional, default: 6):: The number of weeks before EoL when the documentation is considered to be nearing EoL. Can be used to decide when to notify users of the upcoming EoL status.
285
+
286
+ [,yaml]
287
+ ----
288
+ antora:
289
+ extensions:
290
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/end-of-life'
291
+ data:
292
+ eol_settings:
293
+ - component: 'ROOT'
294
+ supported_months: 18
295
+ warning_weeks: 8
296
+ eol_doc: https://support.redpanda.com/hc/en-us/articles/20617574366743-Redpanda-Supported-Versions
297
+ upgrade_doc: ROOT:upgrade:index.adoc
298
+ ----
299
+
300
+ ==== Registration example
301
+
302
+ You can register the extension with a customized configuration for different components in your playbook:
303
+
304
+ [,yaml]
305
+ ----
306
+ antora:
307
+ extensions:
308
+ - require: '@redpanda-data/docs-extensions-and-macros/extensions/compute-end-of-life'
309
+ data:
310
+ eol_settings:
311
+ - component: 'ROOT'
312
+ supported_months: 12
313
+ warning_weeks: 6
314
+ eol_doc: https://example.com/supported-versions
315
+ upgrade_doc: ROOT:upgrade:index.adoc
316
+ - component: 'example-docs'
317
+ supported_months: 24
318
+ warning_weeks: 12
319
+ eol_doc: https://example.com/example-supported-versions
320
+ upgrade_doc: example-docs:upgrade:index.adoc
321
+ ----
322
+
323
+
324
+ ==== Example Handlebars template:
325
+
326
+ [,handlebars]
327
+ ----
328
+ {{#if page.attributes.is-nearing-eol}}
329
+ <div class="banner-container nearing-eol">
330
+ This documentation will reach its end of life on {{page.attributes.eol-date}}.
331
+ Please <a href="{{resolve-resource page.attributes.upgrade-doc}}">upgrade to a supported version</a>.
332
+ </div>
333
+ {{else if page.attributes.is-past-eol}}
334
+ <div class="banner-container past-eol">
335
+ This documentation reached its end of life on {{page.attributes.eol-date}}.
336
+ See our <a href="{{page.attributes.eol-doc}}" target="_blank">supported versions policy</a>.
337
+ </div>
338
+ {{/if}}
339
+ ----
340
+
192
341
  === Generate index data
193
342
 
194
343
  The `generate-index-data` extension creates structured index data about doc pages based on configurable filters. The indexed data is saved to a specified attribute in all component versions, enabling the dynamic generation of categorized links and descriptions within your docs using UI templates.
@@ -463,11 +612,10 @@ antora:
463
612
 
464
613
  === Replace attributes in attachments
465
614
 
466
- This extension replaces AsciiDoc attribute placeholders with their respective values in attachment files, such as CSS, HTML, and YAML.
615
+ This extension automates the replacement of AsciiDoc attribute placeholders with their respective values within attachment files, such as CSS, HTML, and YAML.
467
616
 
468
- [IMPORTANT]
617
+ [NOTE]
469
618
  ====
470
- - This extension processes attachments only if the component version includes the attribute `replace-attributes-in-attachments: true`.
471
619
  - The `@` character is removed from attribute values to prevent potential issues with CSS or HTML syntax.
472
620
  - If the same attribute placeholder is used multiple times within a file, all instances will be replaced with the attribute's value.
473
621
  ====
@@ -478,14 +626,46 @@ This extension does not require any environment variables.
478
626
 
479
627
  ==== Configuration options
480
628
 
481
- There are no configurable options for this extension.
629
+ The extension accepts the following configuration options in the Antora playbook:
482
630
 
483
- ==== Registration example
631
+ data.replacements (required):: An array of replacement configurations. Each configuration can target multiple components and define specific file patterns and custom replacement rules.
632
+
633
+ * `components` (array of strings, required): Lists the names of the Antora components whose attachments should undergo attribute replacement.
634
+
635
+ * `file_patterns` (array of strings, required): Glob patterns specifying which attachment files to process. These patterns determine the files that will undergo attribute replacement based on their paths within the content catalog.
636
+
637
+ * `custom_replacements` (array of objects, optional): Defines custom search-and-replace rules to be applied to the matched files. Each rule consists of:
638
+ ** `search` (string, required): A regular expression pattern to search for within the file content.
639
+ ** `replace` (string, required): The string to replace each match found by the `search` pattern.
640
+
641
+ NOTE: Ensure that `file_patterns` accurately reflect the paths of the attachments you want to process. Overly broad patterns may include unintended files, while overly restrictive patterns might exclude necessary resources.
642
+
643
+ ==== Registration Example
644
+
645
+ This is an example of how to register and configure the `replace-attributes-in-attachments` extension in your Antora playbook. This example demonstrates defining multiple replacement configurations, each targeting different components and specifying their own file patterns and custom replacements.
484
646
 
485
647
  ```yaml
486
648
  antora:
487
649
  extensions:
488
- - '@redpanda-data/docs-extensions-and-macros/extensions/replace-attributes-in-attachments'
650
+ - require: './extensions/replace-attributes-in-attachments'
651
+ data:
652
+ replacements:
653
+ - components:
654
+ - 'ROOT'
655
+ - 'redpanda-labs'
656
+ file_patterns:
657
+ - '**/docker-compose.yaml'
658
+ - '**/docker-compose.yml'
659
+ custom_replacements:
660
+ - search: ''\\$\\{CONFIG_FILE:[^}]*\\}''
661
+ replace: 'console.yaml'
662
+ - components:
663
+ - 'API'
664
+ file_patterns:
665
+ - '**/api-docs/**/resources/**'
666
+ custom_replacements:
667
+ - search: '\\$\\{API_ENDPOINT:[^}]*\\}'
668
+ replace: 'https://api.example.com'
489
669
  ```
490
670
 
491
671
  === Aggregate terms
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const tar = require("tar");
6
+ const micromatch = require("micromatch");
7
+ const { PassThrough } = require("stream");
8
+ const os = require("os"); // For accessing the system's temp directory
9
+
10
+ /**
11
+ * Create a tar.gz archive in memory.
12
+ * @param {string} tempDir - The temporary directory containing files to archive.
13
+ * @returns {Promise<Buffer>} - A promise that resolves to the tar.gz buffer.
14
+ */
15
+ async function createTarInMemory(tempDir) {
16
+ return new Promise((resolve, reject) => {
17
+ const pass = new PassThrough();
18
+ const chunks = [];
19
+
20
+ pass.on("data", (chunk) => chunks.push(chunk));
21
+ pass.on("error", (error) => reject(error));
22
+ pass.on("end", () => resolve(Buffer.concat(chunks)));
23
+
24
+ tar
25
+ .create(
26
+ {
27
+ gzip: true,
28
+ cwd: tempDir,
29
+ },
30
+ ["."]
31
+ )
32
+ .pipe(pass)
33
+ .on("error", (err) => reject(err));
34
+ });
35
+ }
36
+
37
+ module.exports.register = function ({ config }) {
38
+ const logger = this.getLogger("archive-attachments-extension");
39
+ const archives = config.data?.archives || [];
40
+
41
+ // Validate configuration
42
+ if (!archives.length) {
43
+ logger.info("No `archives` configurations provided. Archive creation skipped.");
44
+ return;
45
+ }
46
+
47
+ this.on("beforePublish", async ({ contentCatalog, siteCatalog }) => {
48
+ logger.info("Starting archive creation process");
49
+
50
+ const components = contentCatalog.getComponents();
51
+
52
+ for (const archiveConfig of archives) {
53
+ const { output_archive, component, file_patterns } = archiveConfig;
54
+
55
+ // Validate individual archive configuration
56
+ if (!output_archive) {
57
+ logger.warn("An `archive` configuration is missing `output_archive`. Skipping this archive.");
58
+ continue;
59
+ }
60
+ if (!component) {
61
+ logger.warn(`Archive "${output_archive}" is missing component config. Skipping this archive.`);
62
+ continue;
63
+ }
64
+ if (!file_patterns || !file_patterns.length) {
65
+ logger.warn(`Archive "${output_archive}" has no file_patterns config. Skipping this archive.`);
66
+ continue;
67
+ }
68
+
69
+ logger.debug(`Processing archive: ${output_archive} for component: ${component}`);
70
+
71
+ // Find the specified component
72
+ const comp = components.find((c) => c.name === component);
73
+ if (!comp) {
74
+ logger.warn(`Component "${component}" not found. Skipping archive "${output_archive}".`);
75
+ continue;
76
+ }
77
+
78
+ for (const compVer of comp.versions) {
79
+ const compName = comp.name;
80
+ const compVersion = compVer.version;
81
+ const latest = comp.latest?.version || "not latest";
82
+
83
+ const isLatest = latest === compVersion;
84
+
85
+ logger.debug(`Processing component version: ${compName}@${compVersion}`);
86
+
87
+ // Gather attachments for this component version
88
+ const attachments = contentCatalog.findBy({
89
+ component: compName,
90
+ version: compVersion,
91
+ family: "attachment",
92
+ });
93
+
94
+ logger.debug(`Found ${attachments.length} attachments for ${compName}@${compVersion}`);
95
+
96
+ if (!attachments.length) {
97
+ logger.debug(`No attachments found for ${compName}@${compVersion}, skipping.`);
98
+ continue;
99
+ }
100
+
101
+ // Filter attachments based on file_patterns
102
+ const attachmentsSegment = "_attachments/";
103
+ const matched = attachments.filter((attachment) =>
104
+ micromatch.isMatch(attachment.out.path, file_patterns)
105
+ );
106
+
107
+ logger.debug(`Matched ${matched.length} attachments for ${compName}@${compVersion}`);
108
+
109
+ if (!matched.length) {
110
+ logger.debug(`No attachments matched patterns for ${compName}@${compVersion}, skipping.`);
111
+ continue;
112
+ }
113
+
114
+ // Create a temporary directory and write matched attachments
115
+ let tempDir;
116
+ try {
117
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `${compName}-${compVersion}-`));
118
+ logger.debug(`Created temporary directory: ${tempDir}`);
119
+
120
+ for (const attachment of matched) {
121
+ const relPath = attachment.out.path;
122
+ const attachmentsIndex = relPath.indexOf(attachmentsSegment);
123
+
124
+ if (attachmentsIndex === -1) {
125
+ logger.warn(`'${attachmentsSegment}' segment not found in path: ${relPath}. Skipping this file.`);
126
+ continue;
127
+ }
128
+
129
+ // Extract the path starting after '_attachments/'
130
+ const relativePath = relPath.substring(attachmentsIndex + attachmentsSegment.length);
131
+
132
+ const destPath = path.join(tempDir, relativePath);
133
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
134
+ fs.writeFileSync(destPath, attachment.contents);
135
+ logger.debug(`Written file to tempDir: ${destPath}`);
136
+ }
137
+
138
+ // Asynchronously create the tar.gz archive in memory
139
+ try {
140
+ logger.debug(`Starting tar creation for ${compName}@${compVersion}`);
141
+ const archiveBuffer = await createTarInMemory(tempDir);
142
+ logger.debug(`Tar creation completed for ${compName}@${compVersion}`);
143
+
144
+ // Define the output path for the archive in the site
145
+ const archiveOutPath = `${compVersion ? compVersion + "-" : ""}${output_archive}`.toLowerCase();
146
+
147
+ // Add the archive to siteCatalog
148
+ siteCatalog.addFile({
149
+ contents: archiveBuffer,
150
+ out: { path: archiveOutPath },
151
+ });
152
+
153
+ if (isLatest) {
154
+ siteCatalog.addFile({
155
+ contents: archiveBuffer,
156
+ out: { path: path.basename(output_archive) },
157
+ });
158
+ }
159
+
160
+ logger.info(`Archive "${archiveOutPath}" added to site.`);
161
+ } catch (error) {
162
+ logger.error(`Error creating tar archive for ${compName}@${compVersion}:`, error);
163
+ continue; // Skip further processing for this version
164
+ }
165
+ } catch (error) {
166
+ logger.error(`Error processing ${compName}@${compVersion}:`, error);
167
+ } finally {
168
+ // Clean up the temporary directory
169
+ if (tempDir) {
170
+ try {
171
+ fs.rmSync(tempDir, { recursive: true, force: true });
172
+ logger.debug(`Cleaned up temporary directory: ${tempDir}`);
173
+ } catch (cleanupError) {
174
+ logger.error(`Error cleaning up tempDir "${tempDir}":`, cleanupError);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ logger.info("Archive creation process completed");
182
+ });
183
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const calculateEOL = require('./util/calculate-eol.js');
4
+
5
+ module.exports.register = function ({ config }) {
6
+ this.on('contentClassified', ({ contentCatalog }) => {
7
+ const logger = this.getLogger("compute-end-of-life-extension");
8
+
9
+ // Extract EOL configuration from the config object
10
+ const eolConfigs = config.data?.eol_settings || [];
11
+ if (!Array.isArray(eolConfigs) || eolConfigs.length === 0) {
12
+ logger.warn('No end-of-life settings found in configuration.');
13
+ return;
14
+ }
15
+
16
+ eolConfigs.forEach(({ component: componentName, supported_months, warning_weeks, eol_doc, upgrade_doc }) => {
17
+ if (!eol_doc || !upgrade_doc) {
18
+ logger.error(
19
+ `End-of-life configuration for component "${component}" is missing required attributes. ` +
20
+ `Ensure both "eol_doc" and "upgrade_doc" are specified.`
21
+ );
22
+ return;
23
+ }
24
+ const resolvedEOLMonths = supported_months && supported_months > 0 ? supported_months : 12; // Default: 12 months
25
+ const resolvedWarningWeeks = warning_weeks && warning_weeks > 0 ? warning_weeks : 6; // Default: 6 weeks
26
+
27
+ logger.info(
28
+ `Processing component: ${componentName} with end-of-life months: ${resolvedEOLMonths}, Warning weeks: ${resolvedWarningWeeks}`
29
+ );
30
+
31
+ const component = contentCatalog.getComponents().find((c) => c.name === componentName);
32
+
33
+ if (!component) {
34
+ logger.warn(`Component not found: ${componentName}`);
35
+ return;
36
+ }
37
+
38
+ component.versions.forEach(({ asciidoc, version }) => {
39
+ const releaseDate = asciidoc.attributes['page-release-date'];
40
+ if (releaseDate) {
41
+ // Pass resolved configuration to calculateEOL
42
+ const eolInfo = calculateEOL(releaseDate, resolvedEOLMonths, resolvedWarningWeeks, logger);
43
+ Object.assign(asciidoc.attributes, {
44
+ 'page-is-nearing-eol': eolInfo.isNearingEOL.toString(),
45
+ 'page-is-past-eol': eolInfo.isPastEOL.toString(),
46
+ 'page-eol-date': eolInfo.eolDate,
47
+ 'page-eol-doc': eol_doc,
48
+ 'page-upgrade-doc': upgrade_doc,
49
+ });
50
+ } else {
51
+ logger.warn(`No release date found for component: ${componentName}. Make sure to set {page-release-date} in the antora.yml of the component version ${version}.`);
52
+ }
53
+ });
54
+ });
55
+ });
56
+ };
@@ -1,80 +1,228 @@
1
1
  'use strict';
2
+
2
3
  const semver = require('semver');
4
+ const micromatch = require('micromatch');
5
+ const formatVersion = require('./util/format-version.js');
6
+ const sanitize = require('./util/sanitize-attributes.js');
7
+
8
+ /**
9
+ * Registers the replace attributes extension with support for multiple replacements.
10
+ * Each replacement configuration can target specific components and file patterns.
11
+ *
12
+ * Configuration Structure:
13
+ * data:
14
+ * replacements:
15
+ * - components:
16
+ * - 'ComponentName1'
17
+ * - 'ComponentName2'
18
+ * file_patterns:
19
+ * - 'path/to/attachments/**'
20
+ * - '/another/path/*.adoc'
21
+ * custom_replacements:
22
+ * - search: 'SEARCH_REGEX_PATTERN'
23
+ * replace: 'Replacement String'
24
+ * - ...
25
+ */
26
+ module.exports.register = function ({ config }) {
27
+ const logger = this.getLogger('replace-attributes-extension');
28
+ const replacements = config.data?.replacements || [];
29
+
30
+ // Validate configuration
31
+ if (!replacements.length) {
32
+ logger.info('No `replacements` configurations provided. Replacement process skipped.');
33
+ return;
34
+ }
35
+
36
+ // Precompile all glob matchers for performance
37
+ replacements.forEach((replacementConfig, index) => {
38
+ const { components, file_patterns } = replacementConfig;
39
+
40
+ if (!components || !Array.isArray(components) || !components.length) {
41
+ logger.warn(`Replacement configuration at index ${index} is missing 'components'. Skipping this replacement configuration.`);
42
+ replacementConfig.matchers = null;
43
+ return;
44
+ }
45
+
46
+ if (!file_patterns || !file_patterns.length) {
47
+ logger.warn(`Replacement configuration at index ${index} is missing 'file_patterns'. Skipping this replacement configuration.`);
48
+ replacementConfig.matchers = null;
49
+ return;
50
+ }
51
+
52
+ replacementConfig.matchers = micromatch.matcher(file_patterns, { dot: true });
53
+ });
54
+
55
+ // Precompile all user replacements for each replacement configuration
56
+ replacements.forEach((replacementConfig, index) => {
57
+ const { custom_replacements } = replacementConfig;
58
+ if (!custom_replacements || !custom_replacements.length) {
59
+ replacementConfig.compiledCustomReplacements = [];
60
+ return;
61
+ }
62
+ replacementConfig.compiledCustomReplacements = custom_replacements.map(({ search, replace }) => {
63
+ try {
64
+ return {
65
+ regex: new RegExp(search, 'g'),
66
+ replace,
67
+ };
68
+ } catch (err) {
69
+ logger.error(`Invalid regex pattern in custom_replacements for replacement configuration at index ${index}: "${search}"`, err);
70
+ return null;
71
+ }
72
+ }).filter(Boolean); // Remove any null entries due to invalid regex
73
+ });
3
74
 
4
- module.exports.register = function () {
5
75
  this.on('contentClassified', ({ contentCatalog }) => {
76
+ // Build a lookup table: [componentName][version] -> componentVersion
6
77
  const componentVersionTable = contentCatalog.getComponents().reduce((componentMap, component) => {
7
- componentMap[component.name] = component.versions.reduce((versionMap, componentVersion) => {
8
- versionMap[componentVersion.version] = componentVersion;
78
+ componentMap[component.name] = component.versions.reduce((versionMap, compVer) => {
79
+ versionMap[compVer.version] = compVer;
9
80
  return versionMap;
10
81
  }, {});
11
82
  return componentMap;
12
83
  }, {});
13
84
 
14
- contentCatalog.findBy({ family: 'attachment' }).forEach((attachment) => {
15
- const componentVersion = componentVersionTable[attachment.src.component]?.[attachment.src.version];
16
- if (!componentVersion?.asciidoc?.attributes) return;
85
+ // Iterate over each replacement configuration
86
+ replacements.forEach((replacementConfig, replacementIndex) => {
87
+ const { components, matchers, compiledCustomReplacements } = replacementConfig;
17
88
 
18
- const attributes = Object.entries(componentVersion.asciidoc.attributes).reduce((accum, [name, val]) => {
19
- const stringValue = String(val);
20
- accum[name] = stringValue.endsWith('@') ? sanitizeAttributeValue(stringValue) : stringValue;
21
- return accum;
22
- }, {});
23
-
24
- let contentString = attachment.contents.toString();
25
- let modified = false;
26
-
27
- // Determine if we're using the tag or version attributes
28
- // We introduced tag attributes in Self-Managed 24.3
29
- const isPrerelease = attributes['page-component-version-is-prerelease'];
30
- const componentVersionNumber = formatVersion(componentVersion.version || '');
31
- const useTagAttributes = isPrerelease || (componentVersionNumber && semver.gte(componentVersionNumber, '24.3.0') && componentVersion.title === 'Self-Managed');
32
-
33
- // Set replacements based on the condition
34
- const redpandaVersion = isPrerelease
35
- ? sanitizeAttributeValue(attributes['redpanda-beta-tag'] || '')
36
- : (useTagAttributes
37
- ? sanitizeAttributeValue(attributes['latest-redpanda-tag'] || '')
38
- : sanitizeAttributeValue(attributes['full-version'] || ''));
39
-
40
- const consoleVersion = isPrerelease
41
- ? sanitizeAttributeValue(attributes['console-beta-tag'] || '')
42
- : (useTagAttributes
43
- ? sanitizeAttributeValue(attributes['latest-console-tag'] || '')
44
- : sanitizeAttributeValue(attributes['latest-console-version'] || ''));
45
-
46
- const redpandaRepo = isPrerelease ? 'redpanda-unstable' : 'redpanda';
47
- const consoleRepo = 'console';
48
-
49
- // YAML-specific replacements
50
- if (attachment.out.path.endsWith('.yaml') || attachment.out.path.endsWith('.yml')) {
51
- contentString = replacePlaceholder(contentString, /\$\{REDPANDA_DOCKER_REPO:[^\}]*\}/g, redpandaRepo);
52
- contentString = replacePlaceholder(contentString, /\$\{CONSOLE_DOCKER_REPO:[^\}]*\}/g, consoleRepo);
53
- contentString = replacePlaceholder(contentString, /\$\{REDPANDA_VERSION[^\}]*\}/g, redpandaVersion);
54
- contentString = replacePlaceholder(contentString, /\$\{REDPANDA_CONSOLE_VERSION[^\}]*\}/g, consoleVersion);
55
- modified = true;
89
+ if (!components || !matchers) {
90
+ // Already logged and skipped in precompilation
91
+ return;
56
92
  }
57
93
 
58
- // General attribute replacements (excluding uppercase with underscores)
59
- const result = contentString.replace(/\{([a-z][\p{Alpha}\d_-]*)\}/gu, (match, name) => {
60
- if (!(name in attributes)) return match;
61
- modified = true;
62
- return attributes[name];
63
- });
94
+ components.forEach((componentName) => {
95
+ const comp = contentCatalog.getComponents().find(c => c.name === componentName);
96
+ if (!comp) {
97
+ logger.warn(`Component "${componentName}" not found. Skipping replacement configuration at index ${replacementIndex}.`);
98
+ return;
99
+ }
64
100
 
65
- if (modified) attachment.contents = Buffer.from(result);
66
- });
67
- });
101
+ comp.versions.forEach((compVer) => {
102
+ const compName = comp.name;
103
+ const compVersion = compVer.version;
68
104
 
69
- // Helper function to replace placeholders with attribute values
70
- function replacePlaceholder(content, regex, replacement) {
71
- return content.replace(regex, replacement);
72
- }
105
+ logger.debug(`Processing component version: ${compName}@${compVersion} for replacement configuration at index ${replacementIndex}`);
106
+
107
+ // Gather attachments for this component version
108
+ const attachments = contentCatalog.findBy({
109
+ component: compName,
110
+ version: compVersion,
111
+ family: 'attachment',
112
+ });
113
+
114
+ logger.debug(`Found ${attachments.length} attachments for ${compName}@${compVersion}`);
115
+
116
+ if (!attachments.length) {
117
+ logger.debug(`No attachments found for ${compName}@${compVersion}, skipping.`);
118
+ return;
119
+ }
120
+
121
+ // Filter attachments based on file_patterns
122
+ const matched = attachments.filter((attachment) => {
123
+ const filePath = attachment.out.path;
124
+ return matchers(filePath);
125
+ });
126
+
127
+ logger.debug(`Matched ${matched.length} attachments for ${compName}@${compVersion} in replacement configuration at index ${replacementIndex}`);
73
128
 
74
- const sanitizeAttributeValue = (value) => String(value).replace('@', '');
129
+ if (!matched.length) {
130
+ logger.debug(`No attachments matched patterns for ${compName}@${compVersion} in replacement configuration at index ${replacementIndex}, skipping.`);
131
+ return;
132
+ }
75
133
 
76
- const formatVersion = (version) => {
77
- if (!version) return null;
78
- return semver.valid(version) ? version : `${version}.0`;
79
- };
134
+ // Process each matched attachment
135
+ matched.forEach((attachment) => {
136
+ const { component: attComponent, version: attVersion } = attachment.src;
137
+ const componentVer = componentVersionTable[attComponent]?.[attVersion];
138
+
139
+ if (!componentVer?.asciidoc?.attributes) {
140
+ // Skip attachments without asciidoc attributes
141
+ return;
142
+ }
143
+
144
+ const filePath = attachment.out.path;
145
+
146
+ logger.debug(`Processing attachment: ${filePath} for replacement configuration at index ${replacementIndex}`);
147
+
148
+ // Compute dynamic replacements specific to this componentVersion
149
+ const dynamicReplacements = getDynamicReplacements(componentVer, logger);
150
+
151
+ // Precompile dynamic replacements for this attachment
152
+ const compiledDynamicReplacements = dynamicReplacements.map(({ search, replace }) => ({
153
+ regex: new RegExp(search, 'g'),
154
+ replace,
155
+ }));
156
+
157
+ // Combine dynamic and user replacements
158
+ const allReplacements = [...compiledDynamicReplacements, ...compiledCustomReplacements];
159
+
160
+ // Convert buffer to string once
161
+ let contentStr = attachment.contents.toString('utf8');
162
+
163
+ // Apply all replacements in a single pass
164
+ contentStr = applyAllReplacements(contentStr, allReplacements);
165
+
166
+ // Expand AsciiDoc attributes
167
+ contentStr = expandAsciiDocAttributes(contentStr, componentVer.asciidoc.attributes);
168
+
169
+ // Convert back to buffer
170
+ attachment.contents = Buffer.from(contentStr, 'utf8');
171
+ });
172
+ });
173
+ });
174
+ });
175
+ })
80
176
  };
177
+
178
+ // Build dynamic placeholder replacements
179
+ function getDynamicReplacements(componentVersion, logger) {
180
+ const attrs = componentVersion.asciidoc.attributes;
181
+ const isPrerelease = attrs['page-component-version-is-prerelease'];
182
+ const versionNum = formatVersion(componentVersion.version || '', semver);
183
+ const is24_3plus =
184
+ versionNum && semver.gte(versionNum, '24.3.0') && componentVersion.title === 'Self-Managed';
185
+ const useTagAttributes = isPrerelease || is24_3plus;
186
+
187
+ // Derive Redpanda / Console versions
188
+ const redpandaVersion = isPrerelease
189
+ ? sanitize(attrs['redpanda-beta-tag'] || '')
190
+ : useTagAttributes
191
+ ? sanitize(attrs['latest-redpanda-tag'] || '')
192
+ : sanitize(attrs['full-version'] || '');
193
+ const consoleVersion = isPrerelease
194
+ ? sanitize(attrs['console-beta-tag'] || '')
195
+ : useTagAttributes
196
+ ? sanitize(attrs['latest-console-tag'] || '')
197
+ : sanitize(attrs['latest-console-version'] || '');
198
+
199
+ const redpandaRepo = isPrerelease ? 'redpanda-unstable' : 'redpanda';
200
+ const consoleRepo = 'console';
201
+
202
+ return [
203
+ { search: '\\$\\{REDPANDA_DOCKER_REPO:[^}]*\\}', replace: redpandaRepo },
204
+ { search: '\\$\\{CONSOLE_DOCKER_REPO:[^}]*\\}', replace: consoleRepo },
205
+ { search: '\\$\\{REDPANDA_VERSION[^}]*\\}', replace: redpandaVersion },
206
+ { search: '\\$\\{REDPANDA_CONSOLE_VERSION[^}]*\\}', replace: consoleVersion },
207
+ ];
208
+ }
209
+
210
+ // Apply an array of { regex, replace } to a string in a single pass
211
+ function applyAllReplacements(content, replacements) {
212
+ // Sort replacements by descending length of regex source to handle overlapping patterns
213
+ replacements.sort((a, b) => b.regex.source.length - a.regex.source.length);
214
+
215
+ replacements.forEach(({ regex, replace }) => {
216
+ content = content.replace(regex, replace);
217
+ });
218
+
219
+ return content;
220
+ }
221
+
222
+ // Expand all existing attributes
223
+ function expandAsciiDocAttributes(content, attributes) {
224
+ return content.replace(/\{([a-z][\p{Alpha}\d_-]*)\}/gu, (match, name) => {
225
+ if (!(name in attributes)) return match;
226
+ return sanitize(attributes[name]);
227
+ });
228
+ }
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ module.exports = (releaseDate, eolMonths, warningWeeks, logger) => {
4
+ if (!releaseDate) {
5
+ logger.warn(
6
+ 'No release date provided. Make sure to set {page-release-date} in the antora.yml of the component.'
7
+ );
8
+ return null;
9
+ }
10
+
11
+ // Parse the input date string YYYY-MM-DD in UTC
12
+ const parseUTCDate = (dateString) => {
13
+ const [year, month, day] = dateString.split('-').map(Number);
14
+ // month - 1 because JS months are 0-based
15
+ // https://www.w3schools.com/jsref/jsref_getmonth.asp
16
+ return new Date(Date.UTC(year, month - 1, day));
17
+ };
18
+
19
+ const targetDate = parseUTCDate(releaseDate);
20
+
21
+ if (isNaN(targetDate.getTime())) {
22
+ logger.warn('Invalid release date format:', releaseDate);
23
+ return null;
24
+ }
25
+
26
+ // Calculate EoL date in UTC
27
+ const eolDate = new Date(targetDate.getTime()); // clone
28
+ eolDate.setUTCMonth(eolDate.getUTCMonth() + eolMonths);
29
+
30
+ // Calculate the threshold for warning (X weeks before EoL) in UTC
31
+ const weeksBeforeEOL = new Date(eolDate.getTime());
32
+ weeksBeforeEOL.setUTCDate(weeksBeforeEOL.getUTCDate() - warningWeeks * 7);
33
+
34
+ // Compare times in milliseconds to avoid timezone confusion
35
+ const nowMs = Date.now();
36
+ const eolMs = eolDate.getTime();
37
+ const warningMs = weeksBeforeEOL.getTime();
38
+
39
+ const isNearingEOL = nowMs >= warningMs && nowMs < eolMs;
40
+ const isPastEOL = nowMs > eolMs;
41
+
42
+ // Format the EoL date in UTC
43
+ const humanReadableEOLDate = new Intl.DateTimeFormat('en-US', {
44
+ year: 'numeric',
45
+ month: 'long',
46
+ day: 'numeric',
47
+ timeZone: 'UTC', // Ensure UTC in output
48
+ }).format(eolDate);
49
+
50
+ return {
51
+ isNearingEOL,
52
+ isPastEOL,
53
+ eolDate: humanReadableEOLDate, // For example "March 1, 2025"
54
+ };
55
+ };
@@ -0,0 +1,19 @@
1
+ function customStringify(obj) {
2
+ return JSON.stringify(obj, (key, value) => {
3
+ if (value instanceof Map) {
4
+ return {
5
+ type: 'Map',
6
+ value: Array.from(value.entries())
7
+ };
8
+ } else if (value instanceof Set) {
9
+ return {
10
+ type: 'Set',
11
+ value: Array.from(value)
12
+ };
13
+ } else if (typeof value === 'function') {
14
+ return value.toString();
15
+ } else {
16
+ return value;
17
+ }
18
+ }, 2);
19
+ }
@@ -0,0 +1,7 @@
1
+ /* -----------------------------
2
+ Utility: ensure valid semver or fallback
3
+ ----------------------------- */
4
+ module.exports = (version, semver) => {
5
+ if (!version) return null;
6
+ return semver.valid(version) ? version : `${version}.0`;
7
+ }
@@ -0,0 +1,6 @@
1
+ /* -----------------------------
2
+ Utility: remove trailing '@'
3
+ ----------------------------- */
4
+ module.exports = (val) => {
5
+ return String(val).replace('@', '');
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "3.11.2",
3
+ "version": "4.1.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -30,8 +30,10 @@
30
30
  "require": "./extensions/unlisted-pages.js"
31
31
  },
32
32
  "./extensions/replace-attributes-in-attachments": "./extensions/replace-attributes-in-attachments.js",
33
+ "./extensions/archive-attachments": "./extensions/archive-attachments.js",
33
34
  "./extensions/add-pages-to-root": "./extensions/add-pages-to-root.js",
34
35
  "./extensions/collect-bloblang-samples": "./extensions/collect-bloblang-samples.js",
36
+ "./extensions/compute-end-of-life": "./extensions/compute-end-of-life.js",
35
37
  "./extensions/generate-rp-connect-categories": "./extensions/generate-rp-connect-categories.js",
36
38
  "./extensions/generate-index-data": "./extensions/generate-index-data.js",
37
39
  "./extensions/generate-rp-connect-info": "./extensions/generate-rp-connect-info.js",
@@ -72,9 +74,11 @@
72
74
  "html-entities": "2.3",
73
75
  "js-yaml": "^4.1.0",
74
76
  "lodash": "^4.17.21",
77
+ "micromatch": "^4.0.8",
75
78
  "node-html-parser": "5.4.2-0",
76
79
  "papaparse": "^5.4.1",
77
- "semver": "^7.6.0"
80
+ "semver": "^7.6.0",
81
+ "tar": "^7.4.3"
78
82
  },
79
83
  "devDependencies": {
80
84
  "@antora/cli": "3.1.4",
@@ -1,19 +0,0 @@
1
- function customStringify(obj) {
2
- return JSON.stringify(obj, (key, value) => {
3
- if (value instanceof Map) {
4
- return {
5
- type: 'Map',
6
- value: Array.from(value.entries())
7
- };
8
- } else if (value instanceof Set) {
9
- return {
10
- type: 'Set',
11
- value: Array.from(value)
12
- };
13
- } else if (typeof value === 'function') {
14
- return value.toString();
15
- } else {
16
- return value;
17
- }
18
- }, 2);
19
- }