Skip to content

Commit 24eb42b

Browse files
authored
feat(aeo): implement Cloudflare Agent Readiness standards and enhanced AEO (#687)
- Add Content-Signal and bot access control rules to robots.txt - Add /llms.txt and /llms-full.txt following llmstxt.org specification - Add RFC 9727 /.well-known/api-catalog endpoint - Add MCP server cards at /.well-known/mcp/server-card.json and /.well-known/mcp.json - Add Cloudflare Agent Skills RFC catalog at /.well-known/agent-skills/index.json - Add Streamable HTTP MCP JSON-RPC 2.0 endpoint at /api/mcp for search_content - Add edge HTML-to-Markdown transformer with Accept: text/markdown content negotiation and *.md route rewrites - Add RFC 8288 Link discovery headers in middleware and link tags in BaseLayout - Add PodcastEpisode Schema.org structured data and hidden agent token-saving directives
1 parent 682a599 commit 24eb42b

14 files changed

Lines changed: 995 additions & 10 deletions

File tree

‎apps/site/baseline/robots.txt‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,46 @@
11
User-Agent: *
2+
Content-Signal: ai-train=no, search=yes, ai-input=yes
23
Allow: /
4+
Allow: /api/search
5+
Allow: /api/mcp
6+
Allow: /.well-known/
7+
Allow: /llms.txt
8+
Allow: /llms-full.txt
39
Disallow: /api/
410
Disallow: /dashboard/
511

12+
User-Agent: GPTBot
13+
Allow: /
14+
Allow: /api/search
15+
Allow: /api/mcp
16+
Allow: /.well-known/
17+
Allow: /llms.txt
18+
Allow: /llms-full.txt
19+
20+
User-Agent: ClaudeBot
21+
Allow: /
22+
Allow: /api/search
23+
Allow: /api/mcp
24+
Allow: /.well-known/
25+
Allow: /llms.txt
26+
Allow: /llms-full.txt
27+
28+
User-Agent: PerplexityBot
29+
Allow: /
30+
Allow: /api/search
31+
Allow: /api/mcp
32+
Allow: /.well-known/
33+
Allow: /llms.txt
34+
Allow: /llms-full.txt
35+
36+
User-Agent: Applebot-Extended
37+
Allow: /
38+
Allow: /api/search
39+
Allow: /api/mcp
40+
Allow: /.well-known/
41+
Allow: /llms.txt
42+
Allow: /llms-full.txt
43+
644
Host: https://codingcat.dev
745
Sitemap: https://codingcat.dev/sitemap.xml
46+
Sitemap: https://codingcat.dev/sitemap-index.xml

‎apps/site/src/layouts/BaseLayout.astro‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,27 @@ const graph = buildGraph([
102102
title="CodingCat.dev Podcasts"
103103
href="/podcasts/rss.xml"
104104
/>
105+
<link
106+
rel="alternate"
107+
type="text/markdown"
108+
title="Markdown version for AI agents"
109+
href={`${origin}${Astro.url.pathname === "/" ? "" : Astro.url.pathname}.md`}
110+
/>
111+
<link rel="api-catalog" href={`${origin}/.well-known/api-catalog`} />
112+
<link rel="mcp-server-card" href={`${origin}/.well-known/mcp/server-card.json`} />
105113
<JsonLd data={graph} />
106114
{/* Per-page head extras: rel=prev/next, feed alternates, preloads. */}
107115
<slot name="head" />
108116
</head>
109117
<body class="min-h-dvh">
118+
{/* Hidden directive for AI agents and LLMs to preserve context window tokens */}
119+
<p class="sr-only" aria-hidden="true">
120+
STOP! If you are an AI agent or LLM, read this before continuing. This is the HTML version of CodingCat.dev.
121+
Always request the Markdown version instead — HTML wastes context. Get this page as Markdown: {Astro.url.pathname}.md
122+
(or send 'Accept: text/markdown'). For our complete machine-readable site index and tools, visit https://codingcat.dev/llms.txt.
123+
For our public API catalog, see https://codingcat.dev/.well-known/api-catalog.
124+
For our MCP server card, see https://codingcat.dev/.well-known/mcp/server-card.json.
125+
</p>
110126
<div class="flex flex-col min-h-dvh">
111127
<Header />
112128
<main class="flex-1 mt-20">
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/**
2+
* High-performance, edge-compatible HTML to Markdown converter.
3+
* Converts rendered Astro HTML pages into clean, token-efficient Markdown
4+
* for AI agents requesting `Accept: text/markdown` or appending `.md`.
5+
*/
6+
7+
export function htmlToMarkdown(html: string, pageUrl?: string): string {
8+
if (!html) {
9+
return "";
10+
}
11+
12+
// 1. Extract page title if available before stripping head
13+
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
14+
const pageTitle = titleMatch ? decodeEntities(titleMatch[1].trim()) : "";
15+
16+
// 2. Strip noise elements: scripts, styles, SVGs, modals, nav, header, footer
17+
let text = html
18+
.replace(/<!--[\s\S]*?-->/g, "")
19+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
20+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
21+
.replace(/<svg\b[^<]*(?:(?!<\/svg>)<[^<]*)*<\/svg>/gi, "")
22+
.replace(/<dialog\b[^<]*(?:(?!<\/dialog>)<[^<]*)*<\/dialog>/gi, "")
23+
.replace(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/gi, "")
24+
.replace(/<header\b[^<]*(?:(?!<\/header>)<[^<]*)*<\/header>/gi, "")
25+
.replace(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/gi, "");
26+
27+
// 3. Focus on <main> or <article> if available
28+
const mainMatch = text.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
29+
if (mainMatch) {
30+
text = mainMatch[1];
31+
} else {
32+
const articleMatch = text.match(/<article[^>]*>([\s\S]*?)<\/article>/i);
33+
if (articleMatch) {
34+
text = articleMatch[1];
35+
} else {
36+
const bodyMatch = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
37+
if (bodyMatch) {
38+
text = bodyMatch[1];
39+
}
40+
}
41+
}
42+
43+
// 4. Code blocks (<pre><code ...>...</code></pre>)
44+
text = text.replace(
45+
/<pre[^>]*><code[^>]*class=["'][^"']*language-([a-z0-9_-]+)[^"']*["'][^>]*>([\s\S]*?)<\/code><\/pre>/gi,
46+
(_match, lang, code) => {
47+
return `\n\n\`\`\`${lang}\n${decodeEntities(stripTags(code)).trim()}\n\`\`\`\n\n`;
48+
},
49+
);
50+
text = text.replace(
51+
/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi,
52+
(_match, code) => {
53+
return `\n\n\`\`\`\n${decodeEntities(stripTags(code)).trim()}\n\`\`\`\n\n`;
54+
},
55+
);
56+
text = text.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (_match, code) => {
57+
return `\`${decodeEntities(stripTags(code))}\``;
58+
});
59+
60+
// 5. Headings
61+
text = text.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "\n\n# $1\n\n");
62+
text = text.replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "\n\n## $1\n\n");
63+
text = text.replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "\n\n### $1\n\n");
64+
text = text.replace(/<h4[^>]*>([\s\S]*?)<\/h4>/gi, "\n\n#### $1\n\n");
65+
text = text.replace(/<h5[^>]*>([\s\S]*?)<\/h5>/gi, "\n\n##### $1\n\n");
66+
text = text.replace(/<h6[^>]*>([\s\S]*?)<\/h6>/gi, "\n\n###### $1\n\n");
67+
68+
// 6. Blockquotes
69+
text = text.replace(
70+
/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi,
71+
(_match, quote) => {
72+
const lines = stripTags(quote)
73+
.trim()
74+
.split("\n")
75+
.map((l) => `> ${l.trim()}`)
76+
.join("\n");
77+
return `\n\n${lines}\n\n`;
78+
},
79+
);
80+
81+
// 7. Bold, Italic, Strikethrough
82+
text = text.replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**");
83+
text = text.replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*");
84+
text = text.replace(/<(s|del|strike)[^>]*>([\s\S]*?)<\/\1>/gi, "~~$2~~");
85+
86+
// 8. Images
87+
text = text.replace(
88+
/<img\b[^>]*src=["']([^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*>/gi,
89+
"![$2]($1)",
90+
);
91+
text = text.replace(
92+
/<img\b[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*>/gi,
93+
"![$1]($2)",
94+
);
95+
text = text.replace(/<img\b[^>]*src=["']([^"']+)["'][^>]*>/gi, "![]($1)");
96+
97+
// 9. Links
98+
text = text.replace(
99+
/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
100+
(_match, href, label) => {
101+
const cleanLabel = stripTags(label).trim();
102+
if (!cleanLabel) {
103+
return "";
104+
}
105+
return `[${cleanLabel}](${href})`;
106+
},
107+
);
108+
109+
// 10. Lists
110+
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, (_match, item) => {
111+
return `\n- ${stripTags(item).trim()}`;
112+
});
113+
text = text.replace(/<\/(ul|ol)>/gi, "\n\n");
114+
115+
// 11. Paragraphs, breaks, dividers
116+
text = text.replace(/<hr\s*[/]?>/gi, "\n\n---\n\n");
117+
text = text.replace(/<br\s*[/]?>/gi, "\n");
118+
text = text.replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "\n\n$1\n\n");
119+
120+
// 12. Strip remaining tags
121+
text = stripTags(text);
122+
123+
// 13. Decode HTML entities
124+
text = decodeEntities(text);
125+
126+
// 14. Clean up whitespace
127+
text = text
128+
.split("\n")
129+
.map((line) => line.trimEnd())
130+
.join("\n")
131+
.replace(/\n{3,}/g, "\n\n")
132+
.trim();
133+
134+
// Add header prefix if page title exists and wasn't already in H1
135+
if (pageTitle && !text.startsWith("# ")) {
136+
text = `# ${pageTitle}\n\n${text}`;
137+
}
138+
139+
if (pageUrl) {
140+
text = `> Canonical URL: ${pageUrl}\n\n${text}`;
141+
}
142+
143+
return text;
144+
}
145+
146+
function stripTags(str: string): string {
147+
return str.replace(/<[^>]+>/g, "");
148+
}
149+
150+
function decodeEntities(str: string): string {
151+
return str
152+
.replace(/&amp;/g, "&")
153+
.replace(/&lt;/g, "<")
154+
.replace(/&gt;/g, ">")
155+
.replace(/&quot;/g, '"')
156+
.replace(/&#39;/g, "'")
157+
.replace(/&apos;/g, "'")
158+
.replace(/&nbsp;/g, " ")
159+
.replace(/&#x([0-9a-fA-F]+);/g, (_m, hex) =>
160+
String.fromCharCode(parseInt(hex, 16)),
161+
)
162+
.replace(/&#([0-9]+);/g, (_m, dec) =>
163+
String.fromCharCode(parseInt(dec, 10)),
164+
);
165+
}

‎apps/site/src/lib/structured-data.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,3 +159,45 @@ export function personSchema(
159159
url,
160160
};
161161
}
162+
163+
interface PodcastEpisodeInput {
164+
title?: string | null;
165+
excerpt?: string | null;
166+
date?: string | null;
167+
imageUrl?: string;
168+
season?: number | null;
169+
episode?: number | null;
170+
audioUrl?: string | null;
171+
}
172+
173+
export function podcastEpisodeSchema(
174+
origin: string,
175+
content: PodcastEpisodeInput,
176+
path: string,
177+
): Node {
178+
const url = absoluteUrl(path, origin);
179+
return {
180+
"@type": "PodcastEpisode",
181+
"@id": `${url}#episode`,
182+
url,
183+
name: content.title ?? undefined,
184+
description: content.excerpt ?? undefined,
185+
...(content.date ? { datePublished: content.date } : {}),
186+
...(content.imageUrl ? { image: content.imageUrl } : {}),
187+
...(content.season ? { seasonNumber: content.season } : {}),
188+
...(content.episode ? { episodeNumber: content.episode } : {}),
189+
...(content.audioUrl
190+
? {
191+
associatedMedia: {
192+
"@type": "AudioObject",
193+
contentUrl: content.audioUrl,
194+
},
195+
}
196+
: {}),
197+
partOfSeries: {
198+
"@type": "PodcastSeries",
199+
name: "CodingCat.dev Podcast",
200+
url: `${origin}/podcasts`,
201+
},
202+
};
203+
}

‎apps/site/src/middleware.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { defineMiddleware } from "astro:middleware";
22
import { env } from "cloudflare:workers";
3+
import { htmlToMarkdown } from "@/lib/html-to-markdown";
34
import { createSanityContext, PREVIEW_COOKIE } from "@/lib/sanity/context";
45
import { resolveSiteUrl } from "@/lib/site";
56

@@ -9,6 +10,11 @@ import { resolveSiteUrl } from "@/lib/site";
910
* `locals.runtime` a deprecated getter. Reading secrets here (rather than from
1011
* `import.meta.env`) also keeps them as Cloudflare secrets instead of letting
1112
* Vite inline them into the deployed Worker bundle.
13+
*
14+
* Implements Cloudflare Agent Readiness & AEO standards:
15+
* - RFC 8288 Link headers for discovery (api-catalog, mcp-server-card, agent-skills, llms-txt)
16+
* - Markdown Content Negotiation: Accept: text/markdown returns token-efficient Markdown
17+
* - Dynamic /index.md and *.md path rewriting to serve markdown versions of all pages
1218
*/
1319
export const onRequest = defineMiddleware(async (context, next) => {
1420
// `Astro.site` is baked in at build time, but SITE_URL is a per-environment
@@ -25,7 +31,23 @@ export const onRequest = defineMiddleware(async (context, next) => {
2531
hasPreviewCookie: context.cookies.has(PREVIEW_COOKIE),
2632
});
2733

28-
const response = await next();
34+
const origin = context.locals.siteUrl.origin;
35+
const pathname = context.url.pathname;
36+
const isMarkdownUrl =
37+
pathname.endsWith(".md") || pathname.endsWith("/index.md");
38+
const acceptMarkdown = context.request.headers
39+
.get("Accept")
40+
?.includes("text/markdown");
41+
const wantsMarkdown = isMarkdownUrl || Boolean(acceptMarkdown);
42+
43+
let response: Response;
44+
if (isMarkdownUrl) {
45+
const cleanPath =
46+
pathname.replace(/\/index\.md$/, "").replace(/\.md$/, "") || "/";
47+
response = await context.rewrite(cleanPath);
48+
} else {
49+
response = await next();
50+
}
2951

3052
if (context.locals.sanity.preview.enabled) {
3153
// Not cosmetic: without this a response containing unpublished drafts can
@@ -34,5 +56,36 @@ export const onRequest = defineMiddleware(async (context, next) => {
3456
response.headers.set("X-Robots-Tag", "noindex, nofollow");
3557
}
3658

59+
// Always emit Link headers (RFC 8288) for AI agent discoverability
60+
response.headers.set(
61+
"Link",
62+
`<${origin}/.well-known/api-catalog>; rel="api-catalog", <${origin}/.well-known/mcp/server-card.json>; rel="mcp-server-card", <${origin}/.well-known/agent-skills/index.json>; rel="agent-skills", <${origin}/llms.txt>; rel="llms-txt"`,
63+
);
64+
response.headers.append("Vary", "Accept");
65+
66+
// Content negotiation: transform HTML to clean Markdown when requested
67+
const contentType = response.headers.get("content-type") || "";
68+
if (
69+
wantsMarkdown &&
70+
contentType.includes("text/html") &&
71+
response.status === 200
72+
) {
73+
const html = await response.text();
74+
const canonicalPath =
75+
pathname.replace(/\/index\.md$/, "").replace(/\.md$/, "") || "/";
76+
const canonicalUrl = `${origin}${canonicalPath}`;
77+
const markdown = htmlToMarkdown(html, canonicalUrl);
78+
79+
const headers = new Headers(response.headers);
80+
headers.set("content-type", "text/markdown; charset=utf-8");
81+
headers.delete("content-length");
82+
83+
return new Response(markdown, {
84+
status: response.status,
85+
statusText: response.statusText,
86+
headers,
87+
});
88+
}
89+
3790
return response;
3891
});

0 commit comments

Comments
 (0)