Skip to content
2 changes: 2 additions & 0 deletions astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"astro": "^6.1.0",
"astro-opengraph-images": "^1.14.3",
"astro-redirect-from": "^1.3.5",
"globby": "^16.1.1",
"gray-matter": "^4.0.3",
"astro-rehype-relative-markdown-links": "^0.19.0",
"hast-util-to-text": "^4.0.2",
"jsdom": "^29.0.2",
Expand Down
163 changes: 155 additions & 8 deletions astro/src/plugins/static-redirects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import url from "node:url";
import type { AstroConfig, AstroIntegrationLogger } from "astro";
import path from "node:path";
import fs from "node:fs/promises";
import { globby } from "globby";
import matter from "gray-matter";
Comment thread
maartenba marked this conversation as resolved.

const redirectMap: Record<string, string> = {};
const redirectMap = new Map<string, string>();

export async function configurePlugin(hookOptions: any) {
const buildOutput: string = hookOptions.buildOutput;
Expand All @@ -26,41 +28,186 @@ export async function configurePlugin(hookOptions: any) {

// Build redirect map
logger.info("Generating static redirects file...");
Object.keys(redirects).forEach((from) => {
const redirect = redirects[from];
for (const [from, redirect] of Object.entries(redirects)) {
const destination =
typeof redirect === "string" ? redirect : redirect.destination;

// Normalize: strip trailing slash from source for consistent matching
const normalizedFrom = from.endsWith("/") ? from.slice(0, -1) : from;

// Ensure destination has trailing slash
const normalizedTo = destination.endsWith("/")
? destination
: destination + "/";

redirectMap[normalizedFrom] = normalizedTo;
redirectMap.set(normalizedFrom, normalizedTo);
}

const contentDir = path.join(
url.fileURLToPath(config.srcDir),
"content",
"docs",
);

// Detect duplicate redirect_from entries across content files.
// We cannot detect duplicates from config.redirects alone: astro-redirect-from
// builds a plain JS object from frontmatter, so when two files declare the same
// redirect_from path, the second silently overwrites the first before our hook
// ever runs. Scanning frontmatter directly is the only way to catch these.
await detectDuplicateRedirects(logger, contentDir);
}

/**
* Reads a file in chunks until the closing `---` of the YAML frontmatter block
* is found, then returns only those bytes. This avoids loading the full file
* body. The loop keeps reading until the delimiter is seen or EOF is reached.
*/
async function extractFrontmatterBlock(filePath: string): Promise<string> {
const CHUNK_SIZE = 4096;
const handle = await fs.open(filePath, "r");
try {
let accumulated = "";
let offset = 0;
let firstChunk = true;
// searchFrom tracks how far we've already scanned for the closing delimiter
// so each chunk addition only rescans the newly added bytes.
let searchFrom = 0;

while (true) {
const buf = Buffer.alloc(CHUNK_SIZE);
const { bytesRead } = await handle.read(buf, 0, CHUNK_SIZE, offset);
if (bytesRead === 0) break;

const chunk = buf.toString("utf-8", 0, bytesRead);
accumulated += chunk;
offset += bytesRead;

// After reading the first chunk, bail out early for files without frontmatter
if (firstChunk) {
firstChunk = false;
if (!accumulated.startsWith("---")) return "";
// Skip past the opening --- line before searching for the closing one
searchFrom = accumulated.indexOf("\n") + 1;
}

// Search for the closing --- delimiter starting where we left off
const closingIdx = accumulated.indexOf("\n---", searchFrom);
if (closingIdx !== -1) {
// Include the closing delimiter line in the returned block
const endIdx = accumulated.indexOf("\n", closingIdx + 1);
return endIdx === -1
? accumulated.slice(0, closingIdx + 4)
: accumulated.slice(0, endIdx + 1);
}

// Advance searchFrom so the next iteration only scans the new chunk,
// minus a small overlap to avoid splitting a \n--- across chunk boundaries.
searchFrom = Math.max(searchFrom, accumulated.length - 4);

if (bytesRead < CHUNK_SIZE) break; // reached EOF before closing delimiter
}

// No closing --- found. gray-matter returns empty data for malformed
// frontmatter, so the caller's redirect_from check will safely skip this file.
return accumulated;
} finally {
await handle.close();
}
}

async function detectDuplicateRedirects(
logger: AstroIntegrationLogger,
contentDir: string,
) {
const files = await globby("./**/*.{md,mdx}", {
cwd: contentDir,
gitignore: true,
});

// Map each redirect source path to the list of files that claim it
const sourceToFiles = new Map<string, string[]>();

for (const file of files) {
const filePath = path.join(contentDir, file);

// Read only the frontmatter block, scanning until the closing --- delimiter
// regardless of how large the frontmatter may be.
const frontmatterBlock = await extractFrontmatterBlock(filePath);

if (!frontmatterBlock.includes("redirect_from")) continue;

const { data: frontmatter } = matter(frontmatterBlock);

if (!frontmatter?.redirect_from) continue;

const redirectFrom: string[] = Array.isArray(frontmatter.redirect_from)
? frontmatter.redirect_from
: [frontmatter.redirect_from];

const normalizeRedirectSource = (source: string) => {
const trimmed = source.trim();
if (trimmed !== source) {
logger.warn(
`Trimmed whitespace from redirect_from entry in ${file}: ${JSON.stringify(source)} -> ${JSON.stringify(trimmed)}`,
);
}
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
};

for (const source of redirectFrom) {
const normalized = normalizeRedirectSource(source);
const existing = sourceToFiles.get(normalized);
if (existing) {
existing.push(file);
} else {
sourceToFiles.set(normalized, [file]);
}
}
}

let duplicateCount = 0;
for (const [source, claimingFiles] of sourceToFiles) {
if (claimingFiles.length > 1) {
if (duplicateCount === 0) {
logger.error("Duplicate redirect_from entries detected:");
}
duplicateCount++;
logger.error(
` "${source}" is claimed by ${claimingFiles.length} files: ${claimingFiles.join(", ")}`,
);
}
}

if (duplicateCount > 0) {
throw new Error(
`Build failed: ${duplicateCount} duplicate redirect_from source(s) detected. See log above for details.`,
);
}
}

export async function writeToOutput(hookOptions: any) {
const outDir: string = hookOptions.dir;
const logger: AstroIntegrationLogger = hookOptions.logger;

if (!Object.keys(redirectMap).length) {
if (redirectMap.size === 0) {
logger.warn(
`Skip generating static redirects file: no redirects were generated.`,
);
return;
}

// Write redirects.json
const jsonDestinationPath = path.join(
url.fileURLToPath(outDir),
"redirects.json",
);
await fs.writeFile(jsonDestinationPath, JSON.stringify(redirectMap, null, 2));
await fs.writeFile(
jsonDestinationPath,
JSON.stringify(Object.fromEntries(redirectMap), null, 2),
"utf-8",
);

logger.info(
`Generated ${Object.keys(redirectMap).length} redirects: ${jsonDestinationPath}`,
`Generated ${redirectMap.size} redirects: ${jsonDestinationPath}`,
);
}

Expand Down
Loading