Skip to content

Commit 61bfae8

Browse files
authored
Merge pull request #37 from chinmina/feat/markdown-content-negotiation
feat: add middleware for `Accept: text/markdown` content negotiation
2 parents df1e3f4 + 9f596ec commit 61bfae8

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

functions/_middleware.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expect, it, vi } from "vitest"
2+
import { onRequest } from "./_middleware"
3+
4+
function createContext(
5+
url: string,
6+
options: { method?: string; headers?: Record<string, string> } = {},
7+
) {
8+
const request = new Request(url, {
9+
method: options.method ?? "GET",
10+
headers: options.headers ?? {},
11+
})
12+
const next = vi.fn(() => Promise.resolve(new Response("original")))
13+
return { request, next }
14+
}
15+
16+
describe("markdown content negotiation middleware", () => {
17+
it("redirects GET with Accept: text/markdown to {path}.md", async () => {
18+
const context = createContext("https://example.com/contributing", {
19+
headers: { Accept: "text/markdown" },
20+
})
21+
22+
const response = await onRequest(context)
23+
24+
expect(response.status).toBe(302)
25+
expect(response.headers.get("Location")).toBe("/contributing.md")
26+
expect(context.next).not.toHaveBeenCalled()
27+
})
28+
29+
it("redirects GET on root path to /index.md", async () => {
30+
const context = createContext("https://example.com/", {
31+
headers: { Accept: "text/markdown" },
32+
})
33+
34+
const response = await onRequest(context)
35+
36+
expect(response.status).toBe(302)
37+
expect(response.headers.get("Location")).toBe("/index.md")
38+
expect(context.next).not.toHaveBeenCalled()
39+
})
40+
41+
it("passes through when Accept does not include text/markdown", async () => {
42+
const context = createContext("https://example.com/contributing", {
43+
headers: { Accept: "text/html" },
44+
})
45+
46+
const response = await onRequest(context)
47+
48+
expect(context.next).toHaveBeenCalled()
49+
expect(response).toBe(await context.next.mock.results[0].value)
50+
})
51+
52+
it("passes through for non-GET/HEAD methods", async () => {
53+
const context = createContext("https://example.com/contributing", {
54+
method: "POST",
55+
headers: { Accept: "text/markdown" },
56+
})
57+
58+
await onRequest(context)
59+
60+
expect(context.next).toHaveBeenCalled()
61+
})
62+
63+
it("passes through when URL path has a file extension", async () => {
64+
const context = createContext("https://example.com/styles/main.css", {
65+
headers: { Accept: "text/markdown" },
66+
})
67+
68+
await onRequest(context)
69+
70+
expect(context.next).toHaveBeenCalled()
71+
})
72+
73+
it("redirects HEAD with Accept: text/markdown", async () => {
74+
const context = createContext("https://example.com/contributing", {
75+
method: "HEAD",
76+
headers: { Accept: "text/markdown" },
77+
})
78+
79+
const response = await onRequest(context)
80+
81+
expect(response.status).toBe(302)
82+
expect(response.headers.get("Location")).toBe("/contributing.md")
83+
})
84+
85+
it("redirects when Accept contains text/markdown among other types", async () => {
86+
const context = createContext("https://example.com/contributing", {
87+
headers: { Accept: "text/markdown, text/html;q=0.9" },
88+
})
89+
90+
const response = await onRequest(context)
91+
92+
expect(response.status).toBe(302)
93+
expect(response.headers.get("Location")).toBe("/contributing.md")
94+
})
95+
96+
it("preserves query string in redirect URL", async () => {
97+
const context = createContext(
98+
"https://example.com/contributing?ref=footer",
99+
{
100+
headers: { Accept: "text/markdown" },
101+
},
102+
)
103+
104+
const response = await onRequest(context)
105+
106+
expect(response.status).toBe(302)
107+
expect(response.headers.get("Location")).toBe("/contributing.md?ref=footer")
108+
})
109+
110+
it("strips trailing slash before redirecting", async () => {
111+
const context = createContext("https://example.com/contributing/", {
112+
headers: { Accept: "text/markdown" },
113+
})
114+
115+
const response = await onRequest(context)
116+
117+
expect(response.status).toBe(302)
118+
expect(response.headers.get("Location")).toBe("/contributing.md")
119+
})
120+
121+
it("passes through when URL with trailing slash has a file extension", async () => {
122+
const context = createContext("https://example.com/styles/main.css/", {
123+
headers: { Accept: "text/markdown" },
124+
})
125+
126+
await onRequest(context)
127+
128+
expect(context.next).toHaveBeenCalled()
129+
})
130+
131+
it("passes through when no Accept header is present", async () => {
132+
const context = createContext("https://example.com/contributing")
133+
134+
await onRequest(context)
135+
136+
expect(context.next).toHaveBeenCalled()
137+
})
138+
})

functions/_middleware.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
interface Context {
2+
request: Request
3+
next: () => Promise<Response>
4+
}
5+
6+
export async function onRequest(context: Context): Promise<Response> {
7+
const { request } = context
8+
const method = request.method
9+
if (method !== "GET" && method !== "HEAD") {
10+
return context.next()
11+
}
12+
13+
const accept = request.headers.get("Accept") ?? ""
14+
if (!accept.includes("text/markdown")) {
15+
return context.next()
16+
}
17+
18+
const url = new URL(request.url)
19+
const pathname = cutSuffix(url.pathname, "/")
20+
const lastSegment = cutEnd(pathname, "/")
21+
if (lastSegment.includes(".")) {
22+
return context.next()
23+
}
24+
25+
const path = pathname === "" ? "/index" : pathname
26+
return new Response(null, {
27+
status: 302,
28+
headers: { Location: `${path}.md${url.search}` },
29+
})
30+
}
31+
32+
function cutSuffix(path: string, suffix: string): string {
33+
if (path.endsWith(suffix)) {
34+
return path.slice(0, -suffix.length)
35+
}
36+
return path
37+
}
38+
39+
function cutEnd(str: string, sep: string): string {
40+
const index = str.lastIndexOf(sep)
41+
if (index === -1) {
42+
return str
43+
}
44+
return str.slice(index)
45+
}

0 commit comments

Comments
 (0)