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
9 changes: 9 additions & 0 deletions .changeset/skip-prefix-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@dualmark/cloudflare": patch
"@dualmark/netlify": patch
"@dualmark/vercel": patch
---

Match skip prefixes on a path boundary so a page sharing a prefix is not skipped.

The cloudflare, netlify and vercel adapters tested skip prefixes with a bare `startsWith`, so with the default `["/admin", "/api/", "/_"]` a real page like `/administrator` (or `/admins`) matched `/admin` and was silently excluded from content negotiation, its markdown twin, and the Link header. They now use the same boundary-aware check as the deno and fastly adapters: a prefix matches only the exact path or a `prefix/` subpath (a prefix that already ends in `/` still matches by `startsWith`). Asset paths under `/_` remain covered by the extension list.
7 changes: 6 additions & 1 deletion packages/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ function shouldSkip(
extensions: ReadonlyArray<string>,
): boolean {
if (extensions.some((ext) => pathname.endsWith(ext))) return true;
return prefixes.some((p) => pathname.startsWith(p));
return prefixes.some((p) => {
if (pathname === p) return true;
if (p.endsWith("/")) return pathname.startsWith(p);
// Match on a path boundary so "/admin" does not also skip "/administrator".
return pathname.startsWith(p + "/");
});
}

function normalizePath(pathname: string): string {
Expand Down
14 changes: 14 additions & 0 deletions packages/cloudflare/test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ describe("createAEOWorker — markdown serving", () => {
expect(await res.text()).toBe("# Post 1\n\nBody.");
});

it("does not skip a path that merely shares a skip prefix (/administrator)", async () => {
const worker = createAEOWorker({
upstream: makeUpstream(() => new Response("html", { headers: { "Content-Type": "text/html" } })),
});
const env: TestEnv = { ASSETS: makeAssets({ "/administrator.md": "# Admin Guide" }) };
const req = new Request("https://acme.test/administrator", {
headers: { "user-agent": "GPTBot/1.0" },
});
const res = await worker.fetch(req, env, makeCtx());
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
expect(await res.text()).toBe("# Admin Guide");
});

it("serves markdown when Accept: text/markdown (no bot UA)", async () => {
const worker = createAEOWorker({
upstream: makeUpstream(() => new Response("html", { headers: { "Content-Type": "text/html" } })),
Expand Down
7 changes: 6 additions & 1 deletion packages/netlify/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ function shouldSkip(
extensions: ReadonlyArray<string>,
): boolean {
if (extensions.some((ext) => pathname.endsWith(ext))) return true;
return prefixes.some((p) => pathname.startsWith(p));
return prefixes.some((p) => {
if (pathname === p) return true;
if (p.endsWith("/")) return pathname.startsWith(p);
// Match on a path boundary so "/admin" does not also skip "/administrator".
return pathname.startsWith(p + "/");
});
}

function normalizePath(pathname: string): string {
Expand Down
13 changes: 13 additions & 0 deletions packages/netlify/test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ describe("createAEOWorker — markdown serving", () => {
expect(await res.text()).toBe("# Post 1\n\nBody.");
});

it("does not skip a path that merely shares a skip prefix (/administrator)", async () => {
const worker = createAEOWorker({
assets: makeAssets({ "/administrator.md": "# Admin Guide" }),
});
const req = new Request("https://acme.test/administrator", {
headers: { "user-agent": "GPTBot/1.0" },
});
const res = await worker(req, makeContext());
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
expect(await res.text()).toBe("# Admin Guide");
});

it("serves markdown when Accept: text/markdown (no bot UA)", async () => {
const worker = createAEOWorker({ assets });
const req = new Request("https://acme.test/blog/post-1", {
Expand Down
7 changes: 6 additions & 1 deletion packages/vercel/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ function shouldSkip(
extensions: ReadonlyArray<string>,
): boolean {
if (extensions.some((ext) => pathname.endsWith(ext))) return true;
return prefixes.some((p) => pathname.startsWith(p));
return prefixes.some((p) => {
if (pathname === p) return true;
if (p.endsWith("/")) return pathname.startsWith(p);
// Match on a path boundary so "/admin" does not also skip "/administrator".
return pathname.startsWith(p + "/");
});
}

function normalizePath(pathname: string): string {
Expand Down
16 changes: 16 additions & 0 deletions packages/vercel/test/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ describe("createAEOMiddleware — markdown serving", () => {
expect(await res.text()).toBe("# Post 1\n\nBody.");
});

it("does not skip a path that merely shares a skip prefix (/administrator)", async () => {
const middleware = createAEOMiddleware({
upstream: makeUpstream(
() => new Response("html", { headers: { "Content-Type": "text/html" } }),
),
fetchAsset: makeAssets({ "/administrator.md": "# Admin Guide" }),
});
const req = new Request("https://acme.test/administrator", {
headers: { "user-agent": "GPTBot/1.0" },
});
const res = await middleware(req, makeCtx());
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
expect(await res.text()).toBe("# Admin Guide");
});

it("serves markdown when Accept: text/markdown (no bot UA)", async () => {
const middleware = createAEOMiddleware({
upstream: makeUpstream(
Expand Down