A Stale File in public/ Silently Shadows Its Dynamic Astro Route
Astro resolves public/ before src/pages/. When a static file and a dynamic route generator share a name, the static file wins, the generator is never invoked, and nothing in the build output says so. There is no collision warning and no error.
I found three of these on my own site at once. A checked-in public/robots.txt had been shadowing src/pages/robots.txt.js for months, so every AI crawler allow block I thought I had shipped was sitting in a file that was never served, and the served copy advertised a sitemap URL that returns 404. A public/podcast.xml was shadowing its generator too. That one was worse because it was not visibly broken: both files held the same twelve items, so the feed would have looked correct right up until the thirteenth post, then silently frozen.
The failure mode is specific to generators whose output resembles their stale input closely enough to pass a glance. Diff the served response against what the generator produces, or fail the build on the name collision. Checking that the route returns 200 proves nothing, because the wrong file returns 200 perfectly well.
import { readdirSync, existsSync } from "node:fs";
// A route is shadowed when public/<name> and a src/pages/<name>.{js,ts,astro}
// generator both exist. public/ wins, so the generator is dead code.
export function findShadowedRoutes(publicDir = "public", pagesDir = "src/pages") {
const shadowed = [];
for (const entry of readdirSync(publicDir, { withFileTypes: true })) {
if (entry.isDirectory()) continue;
const generator = [".js", ".ts", ".astro"]
.map((ext) => `${pagesDir}/${entry.name}${ext}`)
.find((candidate) => existsSync(candidate));
if (generator) shadowed.push({ served: `${publicDir}/${entry.name}`, dead: generator });
}
return shadowed;
}
const hits = findShadowedRoutes();
if (hits.length > 0) {
for (const hit of hits) {
console.error(`${hit.served} shadows ${hit.dead}. The generator never runs.`);
}
process.exit(1);
}