Skip to content
2 changes: 1 addition & 1 deletion astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"build": "NODE_OPTIONS=--max_old_space_size=8192 astro build",
Comment thread
maartenba marked this conversation as resolved.
Outdated
Comment thread
maartenba marked this conversation as resolved.
Outdated
"preview": "astro preview",
"astro": "astro",
"linkchecker": "npm run build && lychee --skip-missing --no-progress --max-concurrency 16 --require-https --exclude-path dist/_llms-txt --exclude-path dist/llms.txt --exclude-path dist/llms-full.txt --exclude-path dist/llms-small.txt --exclude sample.duendesoftware.com --exclude docs.duendesoftware.com --exclude sitemap --exclude github --root-dir \"$PWD/dist\" dist/**",
Expand Down
106 changes: 99 additions & 7 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,131 @@ export async function configurePlugin(hookOptions: any) {

// Build redirect map
logger.info("Generating static redirects file...");
Object.keys(redirects).forEach((from) => {
for (const from in redirects) {
const redirect = redirects[from];
Comment thread
maartenba marked this conversation as resolved.
Outdated
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);
}

// 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);
}

async function detectDuplicateRedirects(logger: AstroIntegrationLogger) {
const contentDir = path.join(process.cwd(), "src/content/docs");
Comment thread
maartenba marked this conversation as resolved.
Outdated
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 enough to extract frontmatter — avoid loading full file bodies
const handle = await fs.open(filePath, "r");
try {
const buf = Buffer.alloc(8192);
const { bytesRead } = await handle.read(buf, 0, 8192, 0);
const head = buf.toString("utf-8", 0, bytesRead);

// Quick check: skip files without redirect_from in the frontmatter region
if (!head.includes("redirect_from")) continue;

// Only now read the full file for proper YAML parsing
const content = bytesRead < 8192
? head
: await fs.readFile(filePath, { encoding: "utf-8" });
Comment thread
maartenba marked this conversation as resolved.
Outdated
const { data: frontmatter } = matter(content);

if (!frontmatter?.redirect_from) continue;

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

for (const source of redirectFrom) {
const normalized = source.endsWith("/") ? source.slice(0, -1) : source;
const existing = sourceToFiles.get(normalized);
if (existing) {
existing.push(file);
} else {
sourceToFiles.set(normalized, [file]);
}
}
} finally {
await handle.close();
}
}

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
// Write redirects.json by streaming to avoid a full in-memory JSON string
const jsonDestinationPath = path.join(
url.fileURLToPath(outDir),
"redirects.json",
);
await fs.writeFile(jsonDestinationPath, JSON.stringify(redirectMap, null, 2));
const handle = await fs.open(jsonDestinationPath, "w");
try {
let first = true;
await handle.write("{\n");
for (const [key, value] of redirectMap) {
if (!first) await handle.write(",\n");
await handle.write(` ${JSON.stringify(key)}: ${JSON.stringify(value)}`);
Comment thread
maartenba marked this conversation as resolved.
Outdated
first = false;
}
await handle.write("\n}\n");
} finally {
await handle.close();
}

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

Expand Down
Loading