Skip to content
Draft
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 125 additions & 39 deletions extensions/versions-latest-prerelease/vlp.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,45 @@
// be configured to 'FollowSymlinks'. This extension puts those symlinks in
// place, in the build directory structure, after the Antora build.

// Additionally, this extension modifies 'xref:' links in AsciiDoc files that
// point to 'latest' or 'dev' versions to point to the actual latest stable or
// prerelease version numbers. This ensures that cross-references resolve
// correctly during the Antora build.

// Docs for the standard mechanism:
// https://docs.antora.org/antora/latest/playbook/configure-urls/

// Author: John Krug <[email protected]>
// See: https://docs.antora.org/antora/latest/playbook/configure-urls/

// Requires 'semver' in package.json for version parsing
const semver = require("semver");
const path = require("node:path");
const fs = require("node:fs");

// Enable debug output if VLP_DEBUG env var is set
// Enable debug output if VLP_DEBUG environment variable is set
const debug = process.env.VLP_DEBUG === "true";

/**
* Prints debug messages if debugging is enabled.
* @param {...any} args - Arguments to print.
*/
// Output directory and version info for symlink/file creation
let outputDir = null;
const latestVersionsList = [];
let startPageVersion = null;
let startPageComponent = null;

// Symlink and file names used for version pointers
const LATEST_SYMLINK = "latest";
const DEV_SYMLINK = "dev";
const LATEST_DEV_FILE = "latest_dev.txt";

// Print debug messages if enabled, using dynamic filename label.
const debugLabel = `[${require("node:path").basename(__filename)}]`;
function dprint(...args) {
if (debug) {
console.log("[vlp.js]", ...args);
console.log(debugLabel, ...args);
}
}

/**
* Creates a symlink at symlinkPath pointing to targetPath.
* If symlinkPath exists and is not a directory, it is removed first.
* Directories are never overwritten to avoid accidental data loss.
*/
// Create symlink, avoiding directories.
function createSymlink(targetPath, symlinkPath) {
if (fs.existsSync(symlinkPath)) {
if (!fs.lstatSync(symlinkPath).isDirectory()) {
Expand All @@ -50,29 +61,75 @@ function createSymlink(targetPath, symlinkPath) {
fs.symlinkSync(targetPath, symlinkPath);
}

/**
* Ensures symlink targets are within the intended output directory.
* Prevents directory traversal vulnerabilities.
*/
// Ensure symlink targets are safe.
function isSafePath(base, target) {
const relative = path.relative(base, target);
return !relative.startsWith("..") && !path.isAbsolute(relative);
}

// Output directory and version info for symlink/file creation
let outputDir = null;
const latestVersionsList = [];
let startPageVersion = null;
let startPageComponent = null;
// Rewrite xrefs to latest/dev versions.
function modifyXrefsInText(fileText, file, latestVersionsList) {
const regex = new RegExp(
`xref:(${LATEST_SYMLINK}|${DEV_SYMLINK})@([^:]+):([^\s[]+)`,
"g",
);
let match = regex.exec(fileText);
let newFileText = fileText;
let xrefsModified = 0;
while (match !== null) {
const versionType = match[1];
const targetComponent = match[2];
const targetFile = match[3];
dprint(
`[MODIFIABLE XREF FOUND] Version: ${versionType}, Target Component: ` +
`${targetComponent}, Target File: ${targetFile}, File: ${file.src?.path}`,
);

// Symlink and file names used for version pointers
const LATEST_SYMLINK = "latest";
const DEV_SYMLINK = "dev";
const LATEST_DEV_FILE = "latest_dev.txt";
// Look up the correct version from latestVersionsList
const compEntry = latestVersionsList.find(
(e) => e.componentName === targetComponent,
);
let actualVersion = null;
if (compEntry) {
if (versionType === LATEST_SYMLINK && compEntry.latestStableObj) {
actualVersion = compEntry.latestStableObj.version;
} else if (versionType === DEV_SYMLINK && compEntry.latestPrereleaseObj) {
actualVersion = compEntry.latestPrereleaseObj.version;
}
}
if (actualVersion) {
// Build the new xref with full filename
const newXref = `xref:${actualVersion}@${targetComponent}:${targetFile}`;
dprint(`[XREF MODIFIED] ${newXref}`);
// Replace the original xref in newFileText
const originalXref = match[0];
// Find the line containing the original xref
const lines = newFileText.split(/\r?\n/);
const modifiedLine = lines.find((line) => line.includes(originalXref));
if (modifiedLine) {
dprint(`[MODIFIED LINE] ${modifiedLine}`);
}
newFileText = newFileText.replace(originalXref, newXref);
xrefsModified++;
} else {
dprint(
`[XREF MODIFIED] No version found for ${versionType} in component ` +
`${targetComponent}`,
);
}
match = regex.exec(newFileText);
}
// Print summary if any xrefs were modified
if (xrefsModified > 0) {
console.log(
`${debugLabel} Modified ${xrefsModified} xref(s) in ` +
`file: ${file.src?.path}`,
);
}
return newFileText;
}

/**
* Writes latest_dev.txt for a component, creating the directory if needed.
*/
// Write latest_dev.txt for a component.
function writeLatestDevFile(dir, content) {
try {
fs.mkdirSync(dir, { recursive: true });
Expand All @@ -82,15 +139,8 @@ function writeLatestDevFile(dir, content) {
}
}

/** Antora extension entry point. Registers event handlers for playbookBuilt,
* contentClassified, and sitePublished.
*
* - playbookBuilt: Captures output directory from playbook config
* - contentClassified: Determines latest stable and prerelease versions for
* each component and writes latest_dev.txt files
* - sitePublished: Creates symlinks ('latest', 'dev') for each component to
* their respective versions
*/
// Extension entry point: hooks into playbookBuilt, contentClassified,
// sitePublished.

module.exports.register = function () {
// Capture output directory for later symlink and file creation
Expand Down Expand Up @@ -170,6 +220,41 @@ module.exports.register = function () {
);
writeLatestDevFile(dirName, fileContent);
});

contentCatalog.findBy({ mediaType: "text/asciidoc" }).forEach((file) => {
try {
const basename = file.src?.basename;
const compName = file.component?.name || file.src?.component;
const filename = file.src?.path;

// Skip files that are nav.adoc, in the shared component, or have no
// filename
if (!filename || basename === "nav.adoc" || compName === "shared") {
return;
}

// Output component name and version from file.src if available
if (file.src?.component && file.src?.version) {
dprint(
`[SRC COMPONENT INFO] Component: ${file.src.component}, ` +
`Version: ${file.src.version}, File: ${file.src.path}`,
);
}

// Scan file for 'xref:latest@' or 'xref:dev@' and modify them
const fileText = file.contents?.toString();
if (fileText) {
const newFileText = modifyXrefsInText(
fileText,
file,
latestVersionsList,
);
file.contents = Buffer.from(newFileText);
}
} catch (err) {
console.error(`[vlp.js] Error processing file: ${file.src?.path}`, err);
}
});
});

this.once("sitePublished", () => {
Expand Down Expand Up @@ -228,8 +313,8 @@ module.exports.register = function () {
if (fs.existsSync(latestPath)) {
dprint(
`Updating index.html: ` +
`Replacing /${startPageComponent}/${startPageVersion}/ ` +
`with /${startPageComponent}/latest/`
`Replacing /${startPageComponent}/${startPageVersion}/ ` +
`with /${startPageComponent}/latest/`,
);
// Build a regex to match URLs containing the version
// for this component
Expand All @@ -239,7 +324,8 @@ module.exports.register = function () {
fs.copyFileSync(indexPath, backupPath);
dprint(`Backed up index.html to ${backupPath}`);
const versionPattern = new RegExp(
`(${startPageComponent})/${startPageVersion}(/|\b)`, "g"
`(${startPageComponent})/${startPageVersion}(/|\b)`,
"g",
);
// Perform the replacement in index.html content
indexContent = indexContent.replace(versionPattern, "$1/latest$2");
Expand Down