From 9346eabb9f02c72c10710c416d24a06d018ecee9 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sat, 28 Mar 2026 19:41:28 +1100 Subject: [PATCH 1/3] feat: add Cloudflare Pages middleware for Accept: text/markdown redirect 302-redirects GET/HEAD requests with Accept: text/markdown to {path}.md. Passes through for other methods, paths with file extensions, or missing text/markdown Accept header. Preserves query strings. Root path redirects to /index.md. --- functions/_middleware.test.ts | 117 ++++++++++++++++++++++++++++++++++ functions/_middleware.ts | 29 +++++++++ 2 files changed, 146 insertions(+) create mode 100644 functions/_middleware.test.ts create mode 100644 functions/_middleware.ts diff --git a/functions/_middleware.test.ts b/functions/_middleware.test.ts new file mode 100644 index 0000000..6a3a170 --- /dev/null +++ b/functions/_middleware.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest" +import { onRequest } from "./_middleware" + +function createContext( + url: string, + options: { method?: string; headers?: Record } = {}, +) { + const request = new Request(url, { + method: options.method ?? "GET", + headers: options.headers ?? {}, + }) + const next = vi.fn(() => Promise.resolve(new Response("original"))) + return { request, next } +} + +describe("markdown content negotiation middleware", () => { + it("redirects GET with Accept: text/markdown to {path}.md", async () => { + const context = createContext("https://example.com/contributing", { + headers: { Accept: "text/markdown" }, + }) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/contributing.md") + expect(context.next).not.toHaveBeenCalled() + }) + + it("redirects GET on root path to /index.md", async () => { + const context = createContext("https://example.com/", { + headers: { Accept: "text/markdown" }, + }) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/index.md") + expect(context.next).not.toHaveBeenCalled() + }) + + it("passes through when Accept does not include text/markdown", async () => { + const context = createContext("https://example.com/contributing", { + headers: { Accept: "text/html" }, + }) + + const response = await onRequest(context) + + expect(context.next).toHaveBeenCalled() + expect(response).toBe(await context.next.mock.results[0].value) + }) + + it("passes through for non-GET/HEAD methods", async () => { + const context = createContext("https://example.com/contributing", { + method: "POST", + headers: { Accept: "text/markdown" }, + }) + + const _response = await onRequest(context) + + expect(context.next).toHaveBeenCalled() + }) + + it("passes through when URL path has a file extension", async () => { + const context = createContext("https://example.com/styles/main.css", { + headers: { Accept: "text/markdown" }, + }) + + const _response = await onRequest(context) + + expect(context.next).toHaveBeenCalled() + }) + + it("redirects HEAD with Accept: text/markdown", async () => { + const context = createContext("https://example.com/contributing", { + method: "HEAD", + headers: { Accept: "text/markdown" }, + }) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/contributing.md") + }) + + it("redirects when Accept contains text/markdown among other types", async () => { + const context = createContext("https://example.com/contributing", { + headers: { Accept: "text/markdown, text/html;q=0.9" }, + }) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/contributing.md") + }) + + it("preserves query string in redirect URL", async () => { + const context = createContext( + "https://example.com/contributing?ref=footer", + { + headers: { Accept: "text/markdown" }, + }, + ) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/contributing.md?ref=footer") + }) + + it("passes through when no Accept header is present", async () => { + const context = createContext("https://example.com/contributing") + + const _response = await onRequest(context) + + expect(context.next).toHaveBeenCalled() + }) +}) diff --git a/functions/_middleware.ts b/functions/_middleware.ts new file mode 100644 index 0000000..c9b4402 --- /dev/null +++ b/functions/_middleware.ts @@ -0,0 +1,29 @@ +interface Context { + request: Request + next: () => Promise +} + +export async function onRequest(context: Context): Promise { + const { request } = context + const method = request.method + if (method !== "GET" && method !== "HEAD") { + return context.next() + } + + const url = new URL(request.url) + const lastSegment = url.pathname.split("/").pop() ?? "" + if (lastSegment.includes(".")) { + return context.next() + } + + const accept = request.headers.get("Accept") ?? "" + if (!accept.includes("text/markdown")) { + return context.next() + } + + const path = url.pathname === "/" ? "/index" : url.pathname + return new Response(null, { + status: 302, + headers: { Location: `${path}.md${url.search}` }, + }) +} From 603393b9f25e7cf20a1ec42c6ae48b5f921c2b6f Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:07:12 +1100 Subject: [PATCH 2/3] fix: remove unused variable assignments in middleware tests --- functions/_middleware.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/_middleware.test.ts b/functions/_middleware.test.ts index 6a3a170..1e2057f 100644 --- a/functions/_middleware.test.ts +++ b/functions/_middleware.test.ts @@ -55,7 +55,7 @@ describe("markdown content negotiation middleware", () => { headers: { Accept: "text/markdown" }, }) - const _response = await onRequest(context) + await onRequest(context) expect(context.next).toHaveBeenCalled() }) @@ -65,7 +65,7 @@ describe("markdown content negotiation middleware", () => { headers: { Accept: "text/markdown" }, }) - const _response = await onRequest(context) + await onRequest(context) expect(context.next).toHaveBeenCalled() }) @@ -110,7 +110,7 @@ describe("markdown content negotiation middleware", () => { it("passes through when no Accept header is present", async () => { const context = createContext("https://example.com/contributing") - const _response = await onRequest(context) + await onRequest(context) expect(context.next).toHaveBeenCalled() }) From 9f596eca2a0f6cdc98dbd2006568cc02a03c7962 Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:40:58 +1100 Subject: [PATCH 3/3] fix: normalize trailing slashes in middleware redirect Paths like /about/ produced /about/.md instead of /about.md. Strip trailing slashes before inspecting or constructing the redirect URL. Also reorders checks to parse the URL only after confirming the Accept header matches. --- functions/_middleware.test.ts | 21 +++++++++++++++++++++ functions/_middleware.ts | 28 ++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/functions/_middleware.test.ts b/functions/_middleware.test.ts index 1e2057f..f98e879 100644 --- a/functions/_middleware.test.ts +++ b/functions/_middleware.test.ts @@ -107,6 +107,27 @@ describe("markdown content negotiation middleware", () => { expect(response.headers.get("Location")).toBe("/contributing.md?ref=footer") }) + it("strips trailing slash before redirecting", async () => { + const context = createContext("https://example.com/contributing/", { + headers: { Accept: "text/markdown" }, + }) + + const response = await onRequest(context) + + expect(response.status).toBe(302) + expect(response.headers.get("Location")).toBe("/contributing.md") + }) + + it("passes through when URL with trailing slash has a file extension", async () => { + const context = createContext("https://example.com/styles/main.css/", { + headers: { Accept: "text/markdown" }, + }) + + await onRequest(context) + + expect(context.next).toHaveBeenCalled() + }) + it("passes through when no Accept header is present", async () => { const context = createContext("https://example.com/contributing") diff --git a/functions/_middleware.ts b/functions/_middleware.ts index c9b4402..6a73db3 100644 --- a/functions/_middleware.ts +++ b/functions/_middleware.ts @@ -10,20 +10,36 @@ export async function onRequest(context: Context): Promise { return context.next() } - const url = new URL(request.url) - const lastSegment = url.pathname.split("/").pop() ?? "" - if (lastSegment.includes(".")) { + const accept = request.headers.get("Accept") ?? "" + if (!accept.includes("text/markdown")) { return context.next() } - const accept = request.headers.get("Accept") ?? "" - if (!accept.includes("text/markdown")) { + const url = new URL(request.url) + const pathname = cutSuffix(url.pathname, "/") + const lastSegment = cutEnd(pathname, "/") + if (lastSegment.includes(".")) { return context.next() } - const path = url.pathname === "/" ? "/index" : url.pathname + const path = pathname === "" ? "/index" : pathname return new Response(null, { status: 302, headers: { Location: `${path}.md${url.search}` }, }) } + +function cutSuffix(path: string, suffix: string): string { + if (path.endsWith(suffix)) { + return path.slice(0, -suffix.length) + } + return path +} + +function cutEnd(str: string, sep: string): string { + const index = str.lastIndexOf(sep) + if (index === -1) { + return str + } + return str.slice(index) +}