diff --git a/.changeset/html-content-type-case-insensitive.md b/.changeset/html-content-type-case-insensitive.md
new file mode 100644
index 0000000..14ed4cd
--- /dev/null
+++ b/.changeset/html-content-type-case-insensitive.md
@@ -0,0 +1,11 @@
+---
+"@dualmark/cloudflare": patch
+"@dualmark/deno": patch
+"@dualmark/fastly": patch
+"@dualmark/netlify": patch
+"@dualmark/vercel": patch
+---
+
+Match the HTML `Content-Type` case-insensitively when deciding to inject `Vary: Accept` and the `Link` alternate header.
+
+HTTP media types are case-insensitive (RFC 7231 §3.1.1.1), but the cloudflare, deno, fastly, netlify and vercel adapters gated markdown-twin advertisement on a case-sensitive `contentType.includes("text/html")`. An upstream that emitted `Content-Type: Text/HTML` (or any non-lowercase spelling) skipped the block, so the negotiable HTML response was returned without `Vary: Accept` — risking a shared cache serving HTML to a markdown client — and without the `Link rel="alternate"` twin. The check now lowercases the header first, matching the astro, nuxt and sveltekit adapters which already did this.
diff --git a/packages/cloudflare/src/worker.ts b/packages/cloudflare/src/worker.ts
index 63297b4..5708541 100644
--- a/packages/cloudflare/src/worker.ts
+++ b/packages/cloudflare/src/worker.ts
@@ -284,7 +284,7 @@ export function createAEOWorker(
if (
!shouldSkip(pathname, skipPrefixes, skipExtensions) &&
!pathname.endsWith(".md") &&
- upstreamResponse.headers.get("content-type")?.includes("text/html")
+ upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html")
) {
const newHeaders = new Headers(upstreamResponse.headers);
const vary = newHeaders.get("Vary");
diff --git a/packages/cloudflare/test/worker.test.ts b/packages/cloudflare/test/worker.test.ts
index e0812ca..d448ead 100644
--- a/packages/cloudflare/test/worker.test.ts
+++ b/packages/cloudflare/test/worker.test.ts
@@ -479,4 +479,22 @@ describe("createAEOWorker — Link header injection", () => {
expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
expect(res.headers.get("link")).toBeNull();
});
+
+ it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => {
+ const env: TestEnv = { ASSETS: makeAssets({}) };
+ const worker = createAEOWorker({
+ upstream: makeUpstream(
+ () => new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } }),
+ ),
+ });
+ const req = new Request("https://acme.test/page", {
+ headers: {
+ "user-agent": "Mozilla/5.0 Chrome/130",
+ accept: "text/html,*/*;q=0.8",
+ },
+ });
+ const res = await worker.fetch(req, env, makeCtx());
+ expect(res.headers.get("link")).toContain('rel="alternate"');
+ expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
+ });
});
diff --git a/packages/deno/src/handler.ts b/packages/deno/src/handler.ts
index 403f5d3..73fb3fa 100644
--- a/packages/deno/src/handler.ts
+++ b/packages/deno/src/handler.ts
@@ -275,7 +275,7 @@ export function createAEOHandler(options: CreateAEOHandlerOptions): AEODenoHandl
if (
!shouldSkip(pathname, skipPrefixes, skipExtensions) &&
!pathname.endsWith(".md") &&
- upstreamResponse.headers.get("content-type")?.includes("text/html")
+ upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html")
) {
const newHeaders = new Headers(upstreamResponse.headers);
const vary = newHeaders.get("Vary");
diff --git a/packages/deno/test/handler.test.ts b/packages/deno/test/handler.test.ts
index 85ddd20..4c5b93c 100644
--- a/packages/deno/test/handler.test.ts
+++ b/packages/deno/test/handler.test.ts
@@ -292,6 +292,15 @@ describe("createAEOHandler — Link header injection", () => {
const res = await handler(new Request("https://acme.test/data"), makeInfo());
expect(res.headers.get("link")).toBeNull();
});
+
+ it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => {
+ const upstream: DenoUpstreamHandler = async () =>
+ new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } });
+ const handler = createAEOHandler({ upstream });
+ const res = await handler(new Request("https://acme.test/page"), makeInfo());
+ expect(res.headers.get("link")).toContain('rel="alternate"');
+ expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
+ });
});
describe("createAEOHandler — direct .md handling", () => {
diff --git a/packages/fastly/src/handler.ts b/packages/fastly/src/handler.ts
index 68b2fdc..04828e7 100644
--- a/packages/fastly/src/handler.ts
+++ b/packages/fastly/src/handler.ts
@@ -274,7 +274,7 @@ export function createAEORequestHandler(options: CreateAEOFastlyOptions): AEOFas
if (
!shouldSkip(pathname, skipPrefixes, skipExtensions) &&
!pathname.endsWith(".md") &&
- upstreamResponse.headers.get("content-type")?.includes("text/html")
+ upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html")
) {
const newHeaders = new Headers(upstreamResponse.headers);
const vary = newHeaders.get("Vary");
diff --git a/packages/fastly/test/handler.test.ts b/packages/fastly/test/handler.test.ts
index 432d109..bc56dbf 100644
--- a/packages/fastly/test/handler.test.ts
+++ b/packages/fastly/test/handler.test.ts
@@ -158,6 +158,30 @@ describe("Fastly Adapter", () => {
expect(fetch).toHaveBeenCalledWith(req, { backend: "default_backend" });
});
+ it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ async () =>
+ new Response("", {
+ status: 200,
+ headers: { "Content-Type": "Text/HTML; charset=UTF-8" },
+ }),
+ ),
+ );
+ const handler = createAEORequestHandler({ backend: "default_backend" });
+ const req = new Request("https://acme.test/page", {
+ headers: {
+ "user-agent": "Mozilla/5.0 Chrome/130",
+ accept: "text/html,*/*;q=0.8",
+ },
+ });
+ const res = await handler(req);
+ expect(res.status).toBe(200);
+ expect(res.headers.get("link")).toContain('rel="alternate"');
+ expect(res.headers.get("vary")).toContain("Accept");
+ });
+
it("handles missing .md (cache miss) for bot — falls to upstream HTML", async () => {
const handler = createAEORequestHandler({
backend: "default_backend",
diff --git a/packages/netlify/src/worker.ts b/packages/netlify/src/worker.ts
index 40b6b01..2b21a82 100644
--- a/packages/netlify/src/worker.ts
+++ b/packages/netlify/src/worker.ts
@@ -269,7 +269,7 @@ export function createAEOWorker(
if (
!shouldSkip(pathname, skipPrefixes, skipExtensions) &&
!pathname.endsWith(".md") &&
- originResponse.headers.get("content-type")?.includes("text/html")
+ originResponse.headers.get("content-type")?.toLowerCase().includes("text/html")
) {
const newHeaders = new Headers(originResponse.headers);
const vary = newHeaders.get("Vary");
diff --git a/packages/netlify/test/worker.test.ts b/packages/netlify/test/worker.test.ts
index 25e6661..4124629 100644
--- a/packages/netlify/test/worker.test.ts
+++ b/packages/netlify/test/worker.test.ts
@@ -493,4 +493,23 @@ describe("createAEOWorker — default options", () => {
expect(res.status).toBe(301);
expect(res.headers.get("location")).toBe("https://acme.test/blog?ref=twitter");
});
+
+ it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => {
+ const assets = makeAssets({});
+ const worker = createAEOWorker({ assets });
+ const req = new Request("https://acme.test/page", {
+ headers: {
+ "user-agent": "Mozilla/5.0 Chrome/130",
+ accept: "text/html,*/*;q=0.8",
+ },
+ });
+ const res = await worker(
+ req,
+ makeContext(
+ () => new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } }),
+ ),
+ );
+ expect(res.headers.get("link")).toContain('rel="alternate"');
+ expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
+ });
});
diff --git a/packages/vercel/src/middleware.ts b/packages/vercel/src/middleware.ts
index 7888e18..07e93c9 100644
--- a/packages/vercel/src/middleware.ts
+++ b/packages/vercel/src/middleware.ts
@@ -295,7 +295,7 @@ export function createAEOMiddleware(
const ct = upstreamResponse.headers.get("content-type");
// Passthrough responses (e.g. NextResponse.next()) have no content-type yet —
// always inject. For concrete responses, only inject on text/html.
- if (!ct || ct.includes("text/html")) {
+ if (!ct || ct.toLowerCase().includes("text/html")) {
const mdPath = toMarkdownPath(pathname);
try {
// Fast path: mutate headers in-place (works for NextResponse.next() and
diff --git a/packages/vercel/test/middleware.test.ts b/packages/vercel/test/middleware.test.ts
index f9bc875..afdc359 100644
--- a/packages/vercel/test/middleware.test.ts
+++ b/packages/vercel/test/middleware.test.ts
@@ -446,6 +446,27 @@ describe("createAEOMiddleware — Link header injection", () => {
expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
expect(res.headers.get("link")).toBeNull();
});
+
+ it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => {
+ const middleware = createAEOMiddleware({
+ upstream: makeUpstream(
+ () =>
+ new Response("", {
+ headers: { "Content-Type": "Text/HTML; charset=UTF-8" },
+ }),
+ ),
+ fetchAsset,
+ });
+ const req = new Request("https://acme.test/page", {
+ headers: {
+ "user-agent": "Mozilla/5.0 Chrome/130",
+ accept: "text/html,*/*;q=0.8",
+ },
+ });
+ const res = await middleware(req, makeCtx());
+ expect(res.headers.get("link")).toContain('rel="alternate"');
+ expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept");
+ });
});
describe("createAEOMiddleware — edge cases", () => {