Skip to content

Commit 3bff82d

Browse files
authored
Merge pull request #525 from DeterminateSystems/lucperkins/gtm-256-ai-readiness
Add robots.txt file and Markdown negotiation
2 parents b5de7f3 + 8acf18a commit 3bff82d

7 files changed

Lines changed: 200 additions & 27 deletions

File tree

netlify.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ NODE_VERSION = "24.13.0" # Keep this in step with `nix develop --command node --
1212
[context.production]
1313
environment = { ENV = "production" }
1414

15+
[[edge_functions]]
16+
function = "markdown"
17+
path = "/concepts/*"
18+
excludedPath = ["/concepts/*.md"]
19+
20+
[[edge_functions]]
21+
function = "markdown"
22+
path = "/start/*"
23+
excludedPath = ["/start/*.md"]
24+
1525
[[redirects]]
1626
from = "/quick-start"
1727
to = "/start.html"

netlify/edge-functions/markdown.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// This function provides the desired server dance for .md endpoints
2+
// that perform Markdown content negotiation for AI agents
3+
import type { Context } from "@netlify/edge-functions";
4+
5+
function prefersMarkdown(accept: string | null): boolean {
6+
if (!accept) return false;
7+
let md = 0;
8+
let html = 0;
9+
for (const part of accept.split(",")) {
10+
const [type, ...params] = part
11+
.trim()
12+
.split(";")
13+
.map((s) => s.trim());
14+
const qParam = params.find((p) => p.startsWith("q="));
15+
const q = qParam ? parseFloat(qParam.slice(2)) : 1;
16+
if (Number.isNaN(q)) continue;
17+
if (type === "text/markdown") md = Math.max(md, q);
18+
else if (type === "text/html" || type === "application/xhtml+xml") {
19+
html = Math.max(html, q);
20+
}
21+
}
22+
return md > 0 && md >= html;
23+
}
24+
25+
export default async (request: Request, context: Context) => {
26+
const url = new URL(request.url);
27+
28+
const looksLikeAsset = /\.[a-z0-9]+$/i.test(url.pathname);
29+
if (request.method !== "GET" || looksLikeAsset) {
30+
return context.next();
31+
}
32+
33+
const accept = request.headers.get("Accept");
34+
const mdPath = url.pathname.replace(/\/$/, "") + ".md";
35+
const mdUrl = new URL(mdPath, url);
36+
37+
if (prefersMarkdown(accept)) {
38+
try {
39+
const mdResponse = await fetch(mdUrl, {
40+
headers: { "User-Agent": request.headers.get("User-Agent") ?? "" },
41+
});
42+
const upstreamCt = mdResponse.headers.get("Content-Type") ?? "";
43+
if (mdResponse.ok && upstreamCt.includes("markdown")) {
44+
return new Response(mdResponse.body, {
45+
status: 200,
46+
headers: {
47+
"Content-Type": "text/markdown; charset=utf-8",
48+
Vary: "Accept",
49+
"Cache-Control": "public, max-age=3600",
50+
Link: `<${url.href}>; rel="canonical"`,
51+
},
52+
});
53+
}
54+
} catch {
55+
// Upstream .md fetch failed; falling through to normal HTML response
56+
}
57+
}
58+
59+
// Fell through to normal HTML response due to lack of .md sibling
60+
const response = await context.next();
61+
const ct = response.headers.get("Content-Type") ?? "";
62+
if (ct.includes("text/html")) {
63+
response.headers.append(
64+
"Link",
65+
`<${mdUrl.href}>; rel="alternate"; type="text/markdown"`,
66+
);
67+
response.headers.append("Vary", "Accept");
68+
}
69+
return response;
70+
};

package-lock.json

Lines changed: 36 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"@iconify-json/fa-solid": "^1.2.2",
2626
"@iconify-json/heroicons": "^1.2.3",
2727
"@iconify-json/radix-icons": "^1.2.6",
28+
"@netlify/edge-functions": "^3.0.6",
2829
"@tailwindcss/typography": "^0.5.19",
2930
"@types/alpinejs": "^3.13.11",
3031
"@types/react": "^18.3.28",

public/robots.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
User-agent: *
2+
Content-Signal: search=yes, ai-input=yes, ai-train=yes
3+
Allow: /
4+
5+
Sitemap: https://zero-to-nix.com/sitemap-index.xml

src/pages/concepts/[slug].md.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { APIRoute, GetStaticPaths } from "astro";
2+
import { type CollectionEntry, getCollection } from "astro:content";
3+
4+
export const prerender = true;
5+
6+
type Props = { entry: CollectionEntry<"concepts"> };
7+
8+
const stripExt = (id: string) => id.replace(/\.(md|mdx)$/i, "");
9+
10+
export const getStaticPaths = (async () => {
11+
const entries = await getCollection("concepts");
12+
return entries.map((entry) => ({
13+
params: { slug: stripExt(entry.id) },
14+
props: { entry },
15+
}));
16+
}) satisfies GetStaticPaths;
17+
18+
export const GET: APIRoute<Props> = async ({ props, params }) => {
19+
let entry: CollectionEntry<"concepts"> | undefined = props?.entry;
20+
if (!entry && typeof params.slug === "string") {
21+
const all = await getCollection("concepts");
22+
entry = all.find((e) => stripExt(e.id) === params.slug);
23+
}
24+
if (!entry) {
25+
return new Response("Not Found", { status: 404 });
26+
}
27+
28+
const { title } = entry.data;
29+
const header = [title && `# ${title}`].filter(Boolean).join("\n");
30+
31+
const body = header ? `${header}\n\n${entry.body}` : entry.body;
32+
33+
return new Response(body, {
34+
headers: {
35+
"Content-Type": "text/markdown; charset=utf-8",
36+
"Cache-Control": "public, max-age=3600",
37+
},
38+
});
39+
};

src/pages/start/[slug].md.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { APIRoute, GetStaticPaths } from "astro";
2+
import { type CollectionEntry, getCollection } from "astro:content";
3+
4+
export const prerender = true;
5+
6+
type Props = { entry: CollectionEntry<"start"> };
7+
8+
const stripExt = (id: string) => id.replace(/\.(md|mdx)$/i, "");
9+
10+
export const getStaticPaths = (async () => {
11+
const entries = await getCollection("start");
12+
return entries.map((entry) => ({
13+
params: { slug: stripExt(entry.id) },
14+
props: { entry },
15+
}));
16+
}) satisfies GetStaticPaths;
17+
18+
export const GET: APIRoute<Props> = async ({ props, params }) => {
19+
let entry: CollectionEntry<"start"> | undefined = props?.entry;
20+
if (!entry && typeof params.slug === "string") {
21+
const all = await getCollection("start");
22+
entry = all.find((e) => stripExt(e.id) === params.slug);
23+
}
24+
if (!entry) {
25+
return new Response("Not Found", { status: 404 });
26+
}
27+
28+
const { title } = entry.data;
29+
const header = [title && `# ${title}`].filter(Boolean).join("\n");
30+
31+
const body = header ? `${header}\n\n${entry.body}` : entry.body;
32+
33+
return new Response(body, {
34+
headers: {
35+
"Content-Type": "text/markdown; charset=utf-8",
36+
"Cache-Control": "public, max-age=3600",
37+
},
38+
});
39+
};

0 commit comments

Comments
 (0)