Skip to content

Commit 79ead1a

Browse files
donjothisisjofrank
andauthored
feat: serve markdown source files for AI agent access (#2932)
Co-authored-by: Jo Franchetti <jofranchetti@gmail.com>
1 parent d9607fd commit 79ead1a

File tree

4 files changed

+156
-0
lines changed

4 files changed

+156
-0
lines changed

_config.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import title from "https://deno.land/x/lume_markdown_plugins@v0.7.0/title.ts";
1616
import toc from "https://deno.land/x/lume_markdown_plugins@v0.7.0/toc.ts";
1717
// See note below about GFM CSS
1818
// import { CSS as GFM_CSS } from "https://jsr.io/@deno/gfm/0.11.0/style.ts";
19+
import { walk } from "@std/fs";
20+
import { dirname } from "@std/path";
1921
import { log } from "lume/core/utils/log.ts";
2022
import anchor from "npm:markdown-it-anchor@9";
2123
import admonitionPlugin from "./markdown-it/admonition.ts";
@@ -28,6 +30,7 @@ import createRoutingMiddleware from "./middleware/functionRoutes.ts";
2830
import createGAMiddleware from "./middleware/googleAnalytics.ts";
2931
import redirectsMiddleware from "./middleware/redirects.ts";
3032
import createLlmsFilesMiddleware from "./middleware/llmsFiles.ts";
33+
import createMarkdownSourceMiddleware from "./middleware/markdownSource.ts";
3134
import { toFileAndInMemory } from "./utils/redirects.ts";
3235
import { cliNow } from "./timeUtils.ts";
3336

@@ -73,6 +76,7 @@ const site = lume(
7376
server: {
7477
middlewares: [
7578
redirectsMiddleware,
79+
createMarkdownSourceMiddleware({ root: "_site" }),
7680
createRoutingMiddleware(),
7781
createGAMiddleware({
7882
addr: { transport: "tcp", hostname: "localhost", port: 3000 },
@@ -230,6 +234,49 @@ site.addEventListener("afterBuild", async () => {
230234
log.error("Error generating LLMs files:" + error);
231235
}
232236
}
237+
238+
// Copy source .md files to _site so AI agents can request them directly.
239+
// Excludes "reference/" (dynamically generated, no static .md source files).
240+
const contentDirs = [
241+
"runtime",
242+
"deploy",
243+
"sandbox",
244+
"subhosting",
245+
"examples",
246+
];
247+
let mdCopied = 0;
248+
let mdErrors = false;
249+
for (const dir of contentDirs) {
250+
// Skip directories that don't exist in this build
251+
try {
252+
await Deno.stat(dir);
253+
} catch (error) {
254+
if (!(error instanceof Deno.errors.NotFound)) {
255+
log.error(`Error accessing content directory ${dir}: ${error}`);
256+
}
257+
continue;
258+
}
259+
try {
260+
for await (
261+
const entry of walk(dir, { exts: [".md"], includeDirs: false })
262+
) {
263+
const destPath = site.dest(entry.path);
264+
await Deno.mkdir(dirname(destPath), { recursive: true });
265+
await Deno.copyFile(entry.path, destPath);
266+
mdCopied++;
267+
}
268+
} catch (error) {
269+
log.error(`Error copying markdown files from ${dir}: ${error}`);
270+
mdErrors = true;
271+
}
272+
}
273+
if (mdErrors) {
274+
log.warn(
275+
`Copied ${mdCopied} source markdown files to _site (some directories had errors, see above)`,
276+
);
277+
} else {
278+
log.info(`Copied ${mdCopied} source markdown files to _site`);
279+
}
233280
});
234281

235282
site.copy("reference_gen/gen/deno/page.css", "/api/deno/page.css");

_includes/layout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ export default function Layout(data: Lume.Data) {
5151
type="font/woff2"
5252
crossOrigin="anonymous"
5353
/>
54+
{data.page?.sourcePath?.endsWith(".md") && data.url !== "/" && (
55+
<link
56+
rel="alternate"
57+
type="text/markdown"
58+
href={data.page.sourcePath.endsWith("/index.md")
59+
? `/${data.page.sourcePath}`
60+
: `${data.url.replace(/\/$/, "")}.md`}
61+
/>
62+
)}
5463
<link rel="me" href="https://fosstodon.org/@deno_land" />
5564
<data.comp.OpenGraph
5665
title={data.title}

middleware/markdownSource.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { join } from "@std/path";
2+
import { log } from "lume/core/utils/log.ts";
3+
import type { RequestHandler } from "lume/core/server.ts";
4+
5+
interface MarkdownSourceMiddlewareOptions {
6+
/** Directory where the built static assets live. */
7+
root?: string;
8+
}
9+
10+
/** Reads a markdown file and returns a Response, or null if not found. Throws on other errors. */
11+
async function serveMarkdownFile(
12+
filePath: string,
13+
absoluteRoot: string,
14+
): Promise<Response | null> {
15+
if (!filePath.startsWith(absoluteRoot + "/")) {
16+
return new Response("Forbidden", { status: 403 });
17+
}
18+
try {
19+
const file = await Deno.readFile(filePath);
20+
return new Response(file, {
21+
headers: new Headers({
22+
"content-type": "text/markdown; charset=utf-8",
23+
"cache-control": "public, max-age=300",
24+
}),
25+
});
26+
} catch (error) {
27+
if (error instanceof Deno.errors.NotFound) {
28+
return null;
29+
}
30+
throw error;
31+
}
32+
}
33+
34+
export default function createMarkdownSourceMiddleware(
35+
{ root = "_site" }: MarkdownSourceMiddlewareOptions = {},
36+
): RequestHandler {
37+
const absoluteRoot = root.startsWith("/") ? root : join(Deno.cwd(), root);
38+
39+
return async function markdownSourceMiddleware(req, next) {
40+
const pathname = new URL(req.url).pathname;
41+
42+
// Case A: Direct .md URL request (e.g. /runtime/getting_started/installation.md)
43+
if (pathname.endsWith(".md")) {
44+
const filePath = join(absoluteRoot, pathname.slice(1));
45+
try {
46+
const response = await serveMarkdownFile(filePath, absoluteRoot);
47+
if (response) return response;
48+
// Fallback: /foo/bar.md → /foo/bar/index.md (for index.md source files)
49+
const indexPath = filePath.replace(/\.md$/, "/index.md");
50+
const indexResponse = await serveMarkdownFile(indexPath, absoluteRoot);
51+
return indexResponse ?? next(req);
52+
} catch (error) {
53+
log.error(`Failed serving ${pathname} from ${filePath}: ${error}`);
54+
return new Response("Internal Server Error", { status: 500 });
55+
}
56+
}
57+
58+
// Case B: Accept: text/markdown header — content negotiation
59+
// Tools like Claude Code send this header when they prefer markdown over HTML
60+
const acceptHeader = req.headers.get("accept") ?? "";
61+
if (
62+
acceptHeader.includes("text/markdown") &&
63+
!acceptHeader.includes("text/html")
64+
) {
65+
// Convert the HTML path to its .md equivalent
66+
// e.g. /runtime/getting_started/installation/ -> /runtime/getting_started/installation.md
67+
// Also try the index.md variant for directory-style URLs (e.g. /runtime/contributing/)
68+
const mdPath = pathname.replace(/\/$/, "") + ".md";
69+
const mdIndexPath = pathname.replace(/\/$/, "") + "/index.md";
70+
const filePath = join(absoluteRoot, mdPath.slice(1));
71+
const indexFilePath = join(absoluteRoot, mdIndexPath.slice(1));
72+
try {
73+
const response = (await serveMarkdownFile(filePath, absoluteRoot)) ??
74+
(await serveMarkdownFile(indexFilePath, absoluteRoot));
75+
if (response) {
76+
// Add Vary: Accept so caches don't serve this markdown to requests that want HTML
77+
response.headers.set("vary", "Accept");
78+
return response;
79+
}
80+
// No .md source for this page (e.g. generated API reference) — serve HTML normally.
81+
// Add Vary: Accept so HTTP caches don't serve this HTML to markdown-only requests.
82+
const htmlResponse = await next(req);
83+
const headers = new Headers(htmlResponse.headers);
84+
headers.set("vary", "Accept");
85+
return new Response(htmlResponse.body, {
86+
status: htmlResponse.status,
87+
statusText: htmlResponse.statusText,
88+
headers,
89+
});
90+
} catch (error) {
91+
log.error(`Failed serving markdown for ${pathname}: ${error}`);
92+
return new Response("Internal Server Error", { status: 500 });
93+
}
94+
}
95+
96+
return next(req);
97+
};
98+
}

server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import redirectsMiddleware from "./middleware/redirects.ts";
88
import createRoutingMiddleware from "./middleware/functionRoutes.ts";
99
import expires from "lume/middlewares/expires.ts";
1010
import createLlmsFilesMiddleware from "./middleware/llmsFiles.ts";
11+
import createMarkdownSourceMiddleware from "./middleware/markdownSource.ts";
1112

1213
export const server = new Server({ root: "_site" });
1314

1415
server.use(redirectsMiddleware);
16+
server.use(createMarkdownSourceMiddleware({ root: "_site" }));
1517
server.use(createLlmsFilesMiddleware({ root: "_site" }));
1618
server.use(NotFoundMiddleware({ root: "_site", page404: "./404/" }));
1719
server.use(createRoutingMiddleware());

0 commit comments

Comments
 (0)