Skip to content

Commit 37e848b

Browse files
authored
[codex] Skip core SEO routes when site routes exist (#1386)
* Skip core SEO routes when site routes exist * Address SEO route extension feedback
1 parent 82c7352 commit 37e848b

4 files changed

Lines changed: 139 additions & 11 deletions

File tree

.changeset/seo-route-overrides.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Skips default robots.txt and sitemap.xml route injection when the host site defines its own root routes.

packages/core/src/astro/integration/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
413413
});
414414

415415
// Inject all core routes
416-
injectCoreRoutes(injectRoute);
416+
injectCoreRoutes(injectRoute, { srcDir: astroConfig.srcDir });
417417

418418
// Inject routes from pluggable auth providers (authProviders config)
419419
if (resolvedConfig.authProviders?.length) {

packages/core/src/astro/integration/routes.ts

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Defines and injects all EmDash routes into the Astro application.
55
*/
66

7+
import { existsSync } from "node:fs";
78
import { createRequire } from "node:module";
89
import { dirname, resolve } from "node:path";
910
import { fileURLToPath } from "node:url";
@@ -41,10 +42,42 @@ function resolveRoute(route: string): string {
4142
/** Route injection function type */
4243
type InjectRoute = (route: { pattern: string; entrypoint: string }) => void;
4344

45+
interface InjectCoreRoutesOptions {
46+
srcDir?: URL;
47+
}
48+
49+
const ROUTE_OVERRIDE_EXTENSIONS = [
50+
".astro",
51+
".js",
52+
".ts",
53+
".jsx",
54+
".tsx",
55+
".mjs",
56+
".mts",
57+
".md",
58+
".mdx",
59+
".html",
60+
];
61+
62+
/**
63+
* Detect whether the host site defines its own root-level public route file.
64+
*/
65+
export function hasUserDefinedPublicRoute(srcDir: URL, basename: string): boolean {
66+
const srcDirPath = fileURLToPath(srcDir);
67+
return ROUTE_OVERRIDE_EXTENSIONS.some(
68+
(extension) =>
69+
existsSync(resolve(srcDirPath, "pages", `${basename}${extension}`)) ||
70+
existsSync(resolve(srcDirPath, "pages", basename, `index${extension}`)),
71+
);
72+
}
73+
4474
/**
4575
* Injects all core EmDash routes.
4676
*/
47-
export function injectCoreRoutes(injectRoute: InjectRoute): void {
77+
export function injectCoreRoutes(
78+
injectRoute: InjectRoute,
79+
options: InjectCoreRoutesOptions = {},
80+
): void {
4881
// Inject admin shell route
4982
injectRoute({
5083
pattern: "/_emdash/admin/[...path]",
@@ -752,20 +785,24 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void {
752785
});
753786

754787
// SEO routes (public, at site root)
755-
injectRoute({
756-
pattern: "/sitemap.xml",
757-
entrypoint: resolveRoute("sitemap.xml.ts"),
758-
});
788+
if (!options.srcDir || !hasUserDefinedPublicRoute(options.srcDir, "sitemap.xml")) {
789+
injectRoute({
790+
pattern: "/sitemap.xml",
791+
entrypoint: resolveRoute("sitemap.xml.ts"),
792+
});
793+
}
759794

760795
injectRoute({
761796
pattern: "/sitemap-[collection].xml",
762797
entrypoint: resolveRoute("sitemap-[collection].xml.ts"),
763798
});
764799

765-
injectRoute({
766-
pattern: "/robots.txt",
767-
entrypoint: resolveRoute("robots.txt.ts"),
768-
});
800+
if (!options.srcDir || !hasUserDefinedPublicRoute(options.srcDir, "robots.txt")) {
801+
injectRoute({
802+
pattern: "/robots.txt",
803+
entrypoint: resolveRoute("robots.txt.ts"),
804+
});
805+
}
769806

770807
// Setup wizard API routes
771808
injectRoute({

packages/core/tests/unit/astro/routes.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { dirname, join } from "node:path";
4+
import { pathToFileURL } from "node:url";
5+
16
import { describe, expect, it, vi } from "vitest";
27

3-
import { injectCoreRoutes } from "../../../src/astro/integration/routes.js";
8+
import {
9+
hasUserDefinedPublicRoute,
10+
injectCoreRoutes,
11+
} from "../../../src/astro/integration/routes.js";
412
import { GET as getMediaFile } from "../../../src/astro/routes/api/media/file/[...key].js";
513

614
function mockMediaContext(key: string | undefined) {
@@ -24,6 +32,27 @@ function mockMediaContext(key: string | undefined) {
2432
}
2533

2634
describe("core media route injection", () => {
35+
async function withTempSrcDir(files: Record<string, string>, fn: (srcDir: URL) => void) {
36+
const root = await mkdtemp(join(tmpdir(), "emdash-routes-"));
37+
try {
38+
const srcDir = join(root, "src");
39+
for (const [filePath, contents] of Object.entries(files)) {
40+
const fullPath = join(srcDir, filePath);
41+
await mkdir(dirname(fullPath), { recursive: true });
42+
await writeFile(fullPath, contents);
43+
}
44+
fn(pathToFileURL(srcDir));
45+
} finally {
46+
await rm(root, { recursive: true, force: true });
47+
}
48+
}
49+
50+
function collectRoutePatterns(srcDir?: URL): string[] {
51+
const routes: Array<{ pattern: string; entrypoint: string }> = [];
52+
injectCoreRoutes((route) => routes.push(route), { srcDir });
53+
return routes.map((route) => route.pattern);
54+
}
55+
2756
it("uses a catch-all media file route so storage keys can contain slashes", () => {
2857
const routes: Array<{ pattern: string; entrypoint: string }> = [];
2958
injectCoreRoutes((route) => {
@@ -43,6 +72,63 @@ describe("core media route injection", () => {
4372
}),
4473
);
4574
});
75+
76+
it("injects default root SEO routes when the site does not define them", () => {
77+
const routes = collectRoutePatterns();
78+
79+
expect(routes).toContain("/robots.txt");
80+
expect(routes).toContain("/sitemap.xml");
81+
expect(routes).toContain("/sitemap-[collection].xml");
82+
});
83+
84+
it("skips root SEO routes that are defined by the site", async () => {
85+
await withTempSrcDir(
86+
{
87+
"pages/robots.txt.ts": "export const GET = () => new Response('');",
88+
"pages/sitemap.xml.ts": "export const GET = () => new Response('');",
89+
},
90+
(srcDir) => {
91+
const routes = collectRoutePatterns(srcDir);
92+
93+
expect(routes).not.toContain("/robots.txt");
94+
expect(routes).not.toContain("/sitemap.xml");
95+
expect(routes).toContain("/sitemap-[collection].xml");
96+
},
97+
);
98+
});
99+
100+
it("detects index route files for root public route overrides", async () => {
101+
await withTempSrcDir(
102+
{
103+
"pages/robots.txt/index.ts": "export const GET = () => new Response('');",
104+
},
105+
(srcDir) => {
106+
const routes = collectRoutePatterns(srcDir);
107+
108+
expect(hasUserDefinedPublicRoute(srcDir, "robots.txt")).toBe(true);
109+
expect(hasUserDefinedPublicRoute(srcDir, "sitemap.xml")).toBe(false);
110+
expect(routes).not.toContain("/robots.txt");
111+
expect(routes).toContain("/sitemap.xml");
112+
},
113+
);
114+
});
115+
116+
it("detects markdown and html route files for root public route overrides", async () => {
117+
await withTempSrcDir(
118+
{
119+
"pages/robots.txt.md": "# Robots",
120+
"pages/sitemap.xml/index.html": "<html></html>",
121+
},
122+
(srcDir) => {
123+
const routes = collectRoutePatterns(srcDir);
124+
125+
expect(hasUserDefinedPublicRoute(srcDir, "robots.txt")).toBe(true);
126+
expect(hasUserDefinedPublicRoute(srcDir, "sitemap.xml")).toBe(true);
127+
expect(routes).not.toContain("/robots.txt");
128+
expect(routes).not.toContain("/sitemap.xml");
129+
},
130+
);
131+
});
46132
});
47133

48134
describe("media file catch-all route", () => {

0 commit comments

Comments
 (0)