From c665f239ebce83c5984552708a786312debf990b Mon Sep 17 00:00:00 2001 From: Augustin Bralley Date: Mon, 8 Jun 2026 14:37:39 -0400 Subject: [PATCH 1/2] Skip core SEO routes when site routes exist --- .changeset/seo-route-overrides.md | 5 ++ packages/core/src/astro/integration/index.ts | 2 +- packages/core/src/astro/integration/routes.ts | 44 +++++++++--- packages/core/tests/unit/astro/routes.test.ts | 71 ++++++++++++++++++- 4 files changed, 111 insertions(+), 11 deletions(-) create mode 100644 .changeset/seo-route-overrides.md diff --git a/.changeset/seo-route-overrides.md b/.changeset/seo-route-overrides.md new file mode 100644 index 000000000..70dd46cd3 --- /dev/null +++ b/.changeset/seo-route-overrides.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Skips default robots.txt and sitemap.xml route injection when the host site defines its own root routes. diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index c1aee2607..a98a006c2 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -315,7 +315,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { }); // Inject all core routes - injectCoreRoutes(injectRoute); + injectCoreRoutes(injectRoute, { srcDir: astroConfig.srcDir }); // Inject routes from pluggable auth providers (authProviders config) if (resolvedConfig.authProviders?.length) { diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 0363d7768..50ff90241 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -4,6 +4,7 @@ * Defines and injects all EmDash routes into the Astro application. */ +import { existsSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -41,10 +42,31 @@ function resolveRoute(route: string): string { /** Route injection function type */ type InjectRoute = (route: { pattern: string; entrypoint: string }) => void; +interface InjectCoreRoutesOptions { + srcDir?: URL; +} + +const ROUTE_OVERRIDE_EXTENSIONS = [".astro", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".mts"]; + +/** + * Detect whether the host site defines its own root-level public route file. + */ +export function hasUserDefinedPublicRoute(srcDir: URL, basename: string): boolean { + const srcDirPath = fileURLToPath(srcDir); + return ROUTE_OVERRIDE_EXTENSIONS.some( + (extension) => + existsSync(resolve(srcDirPath, "pages", `${basename}${extension}`)) || + existsSync(resolve(srcDirPath, "pages", basename, `index${extension}`)), + ); +} + /** * Injects all core EmDash routes. */ -export function injectCoreRoutes(injectRoute: InjectRoute): void { +export function injectCoreRoutes( + injectRoute: InjectRoute, + options: InjectCoreRoutesOptions = {}, +): void { // Inject admin shell route injectRoute({ pattern: "/_emdash/admin/[...path]", @@ -746,20 +768,24 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { }); // SEO routes (public, at site root) - injectRoute({ - pattern: "/sitemap.xml", - entrypoint: resolveRoute("sitemap.xml.ts"), - }); + if (!options.srcDir || !hasUserDefinedPublicRoute(options.srcDir, "sitemap.xml")) { + injectRoute({ + pattern: "/sitemap.xml", + entrypoint: resolveRoute("sitemap.xml.ts"), + }); + } injectRoute({ pattern: "/sitemap-[collection].xml", entrypoint: resolveRoute("sitemap-[collection].xml.ts"), }); - injectRoute({ - pattern: "/robots.txt", - entrypoint: resolveRoute("robots.txt.ts"), - }); + if (!options.srcDir || !hasUserDefinedPublicRoute(options.srcDir, "robots.txt")) { + injectRoute({ + pattern: "/robots.txt", + entrypoint: resolveRoute("robots.txt.ts"), + }); + } // Setup wizard API routes injectRoute({ diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index da4ed6850..3ea25fddf 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -1,6 +1,14 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; + import { describe, expect, it, vi } from "vitest"; -import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { + hasUserDefinedPublicRoute, + injectCoreRoutes, +} from "../../../src/astro/integration/routes.js"; import { GET as getMediaFile } from "../../../src/astro/routes/api/media/file/[...key].js"; function mockMediaContext(key: string | undefined) { @@ -24,6 +32,27 @@ function mockMediaContext(key: string | undefined) { } describe("core media route injection", () => { + async function withTempSrcDir(files: Record, fn: (srcDir: URL) => void) { + const root = await mkdtemp(join(tmpdir(), "emdash-routes-")); + try { + const srcDir = join(root, "src"); + for (const [filePath, contents] of Object.entries(files)) { + const fullPath = join(srcDir, filePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents); + } + fn(pathToFileURL(srcDir)); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + + function collectRoutePatterns(srcDir?: URL): string[] { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route), { srcDir }); + return routes.map((route) => route.pattern); + } + it("uses a catch-all media file route so storage keys can contain slashes", () => { const routes: Array<{ pattern: string; entrypoint: string }> = []; injectCoreRoutes((route) => { @@ -43,6 +72,46 @@ describe("core media route injection", () => { }), ); }); + + it("injects default root SEO routes when the site does not define them", () => { + const routes = collectRoutePatterns(); + + expect(routes).toContain("/robots.txt"); + expect(routes).toContain("/sitemap.xml"); + expect(routes).toContain("/sitemap-[collection].xml"); + }); + + it("skips root SEO routes that are defined by the site", async () => { + await withTempSrcDir( + { + "pages/robots.txt.ts": "export const GET = () => new Response('');", + "pages/sitemap.xml.ts": "export const GET = () => new Response('');", + }, + (srcDir) => { + const routes = collectRoutePatterns(srcDir); + + expect(routes).not.toContain("/robots.txt"); + expect(routes).not.toContain("/sitemap.xml"); + expect(routes).toContain("/sitemap-[collection].xml"); + }, + ); + }); + + it("detects index route files for root public route overrides", async () => { + await withTempSrcDir( + { + "pages/robots.txt/index.ts": "export const GET = () => new Response('');", + }, + (srcDir) => { + const routes = collectRoutePatterns(srcDir); + + expect(hasUserDefinedPublicRoute(srcDir, "robots.txt")).toBe(true); + expect(hasUserDefinedPublicRoute(srcDir, "sitemap.xml")).toBe(false); + expect(routes).not.toContain("/robots.txt"); + expect(routes).toContain("/sitemap.xml"); + }, + ); + }); }); describe("media file catch-all route", () => { From 06f3d4bc29f162dece63d0eaa7b17bcc3ddb4dae Mon Sep 17 00:00:00 2001 From: Augustin Bralley Date: Mon, 8 Jun 2026 15:18:05 -0400 Subject: [PATCH 2/2] Address SEO route extension feedback --- packages/core/src/astro/integration/routes.ts | 13 ++++++++++++- packages/core/tests/unit/astro/routes.test.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 50ff90241..68e66d990 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -46,7 +46,18 @@ interface InjectCoreRoutesOptions { srcDir?: URL; } -const ROUTE_OVERRIDE_EXTENSIONS = [".astro", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".mts"]; +const ROUTE_OVERRIDE_EXTENSIONS = [ + ".astro", + ".js", + ".ts", + ".jsx", + ".tsx", + ".mjs", + ".mts", + ".md", + ".mdx", + ".html", +]; /** * Detect whether the host site defines its own root-level public route file. diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index 3ea25fddf..dc632faa9 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -112,6 +112,23 @@ describe("core media route injection", () => { }, ); }); + + it("detects markdown and html route files for root public route overrides", async () => { + await withTempSrcDir( + { + "pages/robots.txt.md": "# Robots", + "pages/sitemap.xml/index.html": "", + }, + (srcDir) => { + const routes = collectRoutePatterns(srcDir); + + expect(hasUserDefinedPublicRoute(srcDir, "robots.txt")).toBe(true); + expect(hasUserDefinedPublicRoute(srcDir, "sitemap.xml")).toBe(true); + expect(routes).not.toContain("/robots.txt"); + expect(routes).not.toContain("/sitemap.xml"); + }, + ); + }); }); describe("media file catch-all route", () => {