diff --git a/functions/_middleware.test.ts b/functions/_middleware.test.ts new file mode 100644 index 0000000..f98e879 --- /dev/null +++ b/functions/_middleware.test.ts @@ -0,0 +1,138 @@ +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" }, + }) + + 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" }, + }) + + 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("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") + + await onRequest(context) + + expect(context.next).toHaveBeenCalled() + }) +}) diff --git a/functions/_middleware.ts b/functions/_middleware.ts new file mode 100644 index 0000000..6a73db3 --- /dev/null +++ b/functions/_middleware.ts @@ -0,0 +1,45 @@ +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 accept = request.headers.get("Accept") ?? "" + if (!accept.includes("text/markdown")) { + return context.next() + } + + const url = new URL(request.url) + const pathname = cutSuffix(url.pathname, "/") + const lastSegment = cutEnd(pathname, "/") + if (lastSegment.includes(".")) { + return context.next() + } + + 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) +}