Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/seo-route-overrides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Skips default robots.txt and sitemap.xml route injection when the host site defines its own root routes.
2 changes: 1 addition & 1 deletion packages/core/src/astro/integration/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
55 changes: 46 additions & 9 deletions packages/core/src/astro/integration/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -41,10 +42,42 @@ 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",
".md",
".mdx",
".html",
];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] ROUTE_OVERRIDE_EXTENSIONS currently misses .md, .mdx, and .html. Astro treats all of these as valid page file extensions in src/pages, so a user with e.g. src/pages/robots.txt.md or src/pages/sitemap.xml.html would still hit duplicate-route warnings that this PR intends to avoid.

Suggested change
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.
*/
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]",
Expand Down Expand Up @@ -746,20 +779,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({
Expand Down
88 changes: 87 additions & 1 deletion packages/core/tests/unit/astro/routes.test.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -24,6 +32,27 @@ function mockMediaContext(key: string | undefined) {
}

describe("core media route injection", () => {
async function withTempSrcDir(files: Record<string, string>, 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) => {
Expand All @@ -43,6 +72,63 @@ 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");
},
);
});

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": "<html></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", () => {
Expand Down
Loading