Skip to content

Commit 6dd1333

Browse files
authored
Add archive attachments extension (#90)
* Add archive attachments extension * Add test * Add support for filtering by component * Apply suggestions from code review * Bump major version * Apply suggestions from review * Apply suggestions from code review
1 parent 4d62877 commit 6dd1333

File tree

9 files changed

+1353
-174
lines changed

9 files changed

+1353
-174
lines changed

README.adoc

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,71 @@ antora:
165165
index-latest-only: true
166166
```
167167

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+
168233
=== Component category aggregator
169234

170235
This extension maps Redpanda Connect component data into a structured format:
@@ -463,11 +528,10 @@ antora:
463528

464529
=== Replace attributes in attachments
465530

466-
This extension replaces AsciiDoc attribute placeholders with their respective values in attachment files, such as CSS, HTML, and YAML.
531+
This extension automates the replacement of AsciiDoc attribute placeholders with their respective values within attachment files, such as CSS, HTML, and YAML.
467532

468-
[IMPORTANT]
533+
[NOTE]
469534
====
470-
- This extension processes attachments only if the component version includes the attribute `replace-attributes-in-attachments: true`.
471535
- The `@` character is removed from attribute values to prevent potential issues with CSS or HTML syntax.
472536
- If the same attribute placeholder is used multiple times within a file, all instances will be replaced with the attribute's value.
473537
====
@@ -478,14 +542,46 @@ This extension does not require any environment variables.
478542

479543
==== Configuration options
480544

481-
There are no configurable options for this extension.
545+
The extension accepts the following configuration options in the Antora playbook:
482546

483-
==== Registration example
547+
data.replacements (required):: An array of replacement configurations. Each configuration can target multiple components and define specific file patterns and custom replacement rules.
548+
549+
* `components` (array of strings, required): Lists the names of the Antora components whose attachments should undergo attribute replacement.
550+
551+
* `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.
552+
553+
* `custom_replacements` (array of objects, optional): Defines custom search-and-replace rules to be applied to the matched files. Each rule consists of:
554+
** `search` (string, required): A regular expression pattern to search for within the file content.
555+
** `replace` (string, required): The string to replace each match found by the `search` pattern.
556+
557+
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.
558+
559+
==== Registration Example
560+
561+
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.
484562

485563
```yaml
486564
antora:
487565
extensions:
488-
- '@redpanda-data/docs-extensions-and-macros/extensions/replace-attributes-in-attachments'
566+
- require: './extensions/replace-attributes-in-attachments'
567+
data:
568+
replacements:
569+
- components:
570+
- 'ROOT'
571+
- 'redpanda-labs'
572+
file_patterns:
573+
- '**/docker-compose.yaml'
574+
- '**/docker-compose.yml'
575+
custom_replacements:
576+
- search: ''\\$\\{CONFIG_FILE:[^}]*\\}''
577+
replace: 'console.yaml'
578+
- components:
579+
- 'API'
580+
file_patterns:
581+
- '**/api-docs/**/resources/**'
582+
custom_replacements:
583+
- search: '\\$\\{API_ENDPOINT:[^}]*\\}'
584+
replace: 'https://api.example.com'
489585
```
490586

491587
=== Aggregate terms

extensions/archive-attachments.js

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)