diff --git a/astro/package.json b/astro/package.json index b6a46108..0499951f 100644 --- a/astro/package.json +++ b/astro/package.json @@ -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", diff --git a/astro/src/plugins/static-redirects.ts b/astro/src/plugins/static-redirects.ts index c08758a3..4a8b4d25 100644 --- a/astro/src/plugins/static-redirects.ts +++ b/astro/src/plugins/static-redirects.ts @@ -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"; -const redirectMap: Record = {}; +const redirectMap = new Map(); export async function configurePlugin(hookOptions: any) { const buildOutput: string = hookOptions.buildOutput; @@ -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 { + 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(); + + 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}`, ); }