Skip to content

Commit a1c44ab

Browse files
authored
fix: run loaders before markdown negotiation (#161)
1 parent 72472ed commit a1c44ab

5 files changed

Lines changed: 54 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pracht/core": patch
3+
---
4+
5+
Fix Markdown-for-Agents negotiation so route loaders and document headers still run before returning markdown responses, preventing loader auth/header bypass.

docs/DATA_LOADING.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ export. Named route exports such as `loader`, `head`, `headers`, `markdown`,
3232

3333
A `markdown` string export opts the route into Markdown-for-Agents content
3434
negotiation: when a request arrives with `Accept: text/markdown`, the runtime
35-
returns the raw markdown source with `Content-Type: text/markdown` instead of
36-
rendering the component.
35+
still executes middleware, the route loader, and document header resolution
36+
first, then returns the raw markdown source with `Content-Type: text/markdown`
37+
instead of rendering the component.
3738

3839
### LoaderArgs
3940

packages/framework/src/runtime-negotiation.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,9 @@ export function prefersMarkdown(accept: string | null): boolean {
4040
return md.quality >= html.quality;
4141
}
4242

43-
export function markdownResponse(source: string): Response {
44-
const headers = new Headers({
45-
"content-type": "text/markdown; charset=utf-8",
46-
"cache-control": "public, max-age=0, must-revalidate",
47-
});
43+
export function markdownResponse(source: string, initHeaders?: HeadersInit): Response {
44+
const headers = new Headers(initHeaders);
45+
headers.set("content-type", "text/markdown; charset=utf-8");
4846
appendVaryHeader(headers, "Accept");
4947
applyDefaultSecurityHeaders(headers);
5048
return new Response(source, { status: 200, headers });

packages/framework/src/runtime.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -371,17 +371,6 @@ export async function handlePrachtRequest<TContext>(
371371
throw new Error("Route module not found");
372372
}
373373

374-
// Markdown-for-Agents negotiation: if the route exposes raw markdown
375-
// and the client prefers `text/markdown`, skip render and return the
376-
// source.
377-
if (
378-
!isRouteStateRequest &&
379-
typeof routeModule.markdown === "string" &&
380-
prefersMarkdown(options.request.headers.get("accept"))
381-
) {
382-
return markdownResponse(routeModule.markdown);
383-
}
384-
385374
currentPhase = "loader";
386375
const { loader, loaderFile: resolvedLoaderFile } = await dataFunctionsPromise;
387376
loaderFile = resolvedLoaderFile;
@@ -410,6 +399,16 @@ export async function handlePrachtRequest<TContext>(
410399
mergeDocumentHeaders(shellModule, routeModule, routeArgs, data),
411400
]);
412401

402+
// Markdown-for-Agents negotiation must run after loader + header
403+
// resolution so auth redirects/401s and cache policies still apply.
404+
if (
405+
!isRouteStateRequest &&
406+
typeof routeModule.markdown === "string" &&
407+
prefersMarkdown(options.request.headers.get("accept"))
408+
) {
409+
return markdownResponse(routeModule.markdown, documentHeaders);
410+
}
411+
413412
const cssUrls = resolvePageCssUrls(
414413
options.cssManifest,
415414
match.route.shellFile,

packages/framework/test/runtime-negotiation.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,37 @@ describe("handlePrachtRequest markdown negotiation", () => {
9898
expect(response.status).toBe(200);
9999
expect(response.headers.get("content-type")?.includes("text/html")).toBe(true);
100100
});
101+
102+
it("runs loader and preserves document headers before returning markdown", async () => {
103+
let loaderCalls = 0;
104+
const response = await handlePrachtRequest({
105+
app,
106+
registry: {
107+
routeModules: {
108+
"./routes/home.md": async () => ({
109+
markdown: "# Home\n",
110+
loader: () => {
111+
loaderCalls += 1;
112+
return { ok: true };
113+
},
114+
headers: () => ({
115+
"cache-control": "private, no-store",
116+
"x-route-headers": "yes",
117+
}),
118+
Component: () => null,
119+
}),
120+
},
121+
},
122+
request: new Request("http://localhost/", {
123+
headers: { accept: "text/markdown" },
124+
}),
125+
});
126+
127+
expect(loaderCalls).toBe(1);
128+
expect(response.status).toBe(200);
129+
expect(response.headers.get("content-type")).toBe("text/markdown; charset=utf-8");
130+
expect(response.headers.get("cache-control")).toBe("private, no-store");
131+
expect(response.headers.get("x-route-headers")).toBe("yes");
132+
expect(await response.text()).toBe("# Home\n");
133+
});
101134
});

0 commit comments

Comments
 (0)