Skip to content

Commit b7c9e71

Browse files
authored
Add markdown and llm.txt to docs (#6408)
* add markdown and llm.txt to docs * rename markdown-clean to markdown, sync cache-control headers, add edge injection * revert sst-env.d.ts * guard entry.body instead of non-null assertion * use consistent newlines in markdown template literals * rename utils/ to util/, add edge injection comments * strip tsdoc HTML artifacts from markdown output
1 parent 6a512eb commit b7c9e71

File tree

6 files changed

+212
-0
lines changed

6 files changed

+212
-0
lines changed

www/src/pages/docs/[...slug].md.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { getCollection, getEntry } from "astro:content";
2+
import type { APIRoute } from "astro";
3+
import { cleanMarkdown } from "../../util/markdown";
4+
5+
export async function getStaticPaths() {
6+
const docs = await getCollection("docs");
7+
return docs
8+
.filter(
9+
(doc) =>
10+
doc.id.startsWith("docs/") &&
11+
doc.id !== "docs/index.mdx"
12+
)
13+
.map((doc) => ({
14+
params: { slug: doc.id.replace(/^docs\//, "").replace(/\.mdx?$/, "") },
15+
}));
16+
}
17+
18+
export const GET: APIRoute = async ({ params }) => {
19+
const slug = params.slug!;
20+
const entry = await getEntry("docs", `docs/${slug}`);
21+
if (!entry?.body) return new Response("Not found", { status: 404 });
22+
23+
const cleaned = cleanMarkdown(entry.body);
24+
const markdown = `# ${entry.data.title}
25+
26+
${entry.data.description || ""}
27+
28+
Source: https://sst.dev/docs/${slug}
29+
30+
---
31+
32+
${cleaned}`;
33+
34+
return new Response(markdown, {
35+
headers: {
36+
"Content-Type": "text/markdown; charset=utf-8",
37+
"Cache-Control": "public,max-age=0,s-maxage=86400,stale-while-revalidate=86400",
38+
},
39+
});
40+
};

www/src/pages/docs/index.md.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { getEntry } from "astro:content";
2+
import type { APIRoute } from "astro";
3+
import { cleanMarkdown } from "../../util/markdown";
4+
5+
export const GET: APIRoute = async () => {
6+
const entry = await getEntry("docs", "docs");
7+
if (!entry?.body) return new Response("Not found", { status: 404 });
8+
9+
const cleaned = cleanMarkdown(entry.body);
10+
const markdown = `# ${entry.data.title}
11+
12+
${entry.data.description || ""}
13+
14+
Source: https://sst.dev/docs
15+
16+
---
17+
18+
${cleaned}`;
19+
20+
return new Response(markdown, {
21+
headers: {
22+
"Content-Type": "text/markdown; charset=utf-8",
23+
"Cache-Control": "public,max-age=0,s-maxage=86400,stale-while-revalidate=86400",
24+
},
25+
});
26+
};

www/src/pages/llms-full.txt.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getCollection } from "astro:content";
2+
import type { APIRoute } from "astro";
3+
import { cleanMarkdown } from "../util/markdown";
4+
5+
export const GET: APIRoute = async () => {
6+
const docs = await getCollection("docs");
7+
const filtered = docs
8+
.filter((doc) => doc.id.startsWith("docs/"))
9+
.sort((a, b) => a.id.localeCompare(b.id));
10+
11+
const pages = filtered.map((doc) => {
12+
const slug = doc.id.replace(/\.mdx?$/, "");
13+
const cleaned = cleanMarkdown(doc.body || "");
14+
return `## ${doc.data.title}
15+
16+
${doc.data.description || ""}
17+
18+
https://sst.dev/${slug}
19+
20+
${cleaned}`;
21+
});
22+
23+
const body = `# SST Documentation
24+
25+
> The complete SST documentation for building full-stack applications on AWS and Cloudflare.
26+
27+
${pages.join("\n\n---\n\n")}
28+
`;
29+
30+
return new Response(body, {
31+
headers: {
32+
"Content-Type": "text/plain; charset=utf-8",
33+
"Cache-Control": "public,max-age=0,s-maxage=86400,stale-while-revalidate=86400",
34+
},
35+
});
36+
};

www/src/pages/llms.txt.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getCollection } from "astro:content";
2+
import type { APIRoute } from "astro";
3+
4+
export const GET: APIRoute = async () => {
5+
const docs = await getCollection("docs");
6+
const filtered = docs
7+
.filter((doc) => doc.id.startsWith("docs/"))
8+
.sort((a, b) => a.id.localeCompare(b.id));
9+
10+
const links = filtered
11+
.map((doc) => {
12+
const slug = doc.id.replace(/\.mdx?$/, "");
13+
const description = doc.data.description || "";
14+
return `- [${doc.data.title}](https://sst.dev/${slug})${description ? `: ${description}` : ""}`;
15+
})
16+
.join("\n");
17+
18+
const body = `# SST
19+
20+
> SST is a framework for building full-stack apps on your own infrastructure with support for AWS, Cloudflare, and 150+ providers.
21+
22+
## Docs
23+
24+
${links}
25+
`;
26+
27+
return new Response(body, {
28+
headers: {
29+
"Content-Type": "text/plain; charset=utf-8",
30+
"Cache-Control": "public,max-age=0,s-maxage=86400,stale-while-revalidate=86400",
31+
},
32+
});
33+
};

www/src/util/markdown.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export function cleanMarkdown(source: string): string {
2+
return (
3+
source
4+
// Remove import statements
5+
.replace(/^import\s+.*$/gm, "")
6+
// Remove export statements
7+
.replace(/^export\s+.*$/gm, "")
8+
// Remove JSX comments {/* ... */} (single and multiline)
9+
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
10+
// Remove <Image ... /> self-closing tags
11+
.replace(/<Image\s+[^>]*\/>/g, "")
12+
// Remove <VideoAside ... /> self-closing tags
13+
.replace(/<VideoAside\s+[^>]*\/>/g, "")
14+
// Remove <LinkCard ... /> self-closing tags
15+
.replace(/<LinkCard\s+[^>]*\/>/g, "")
16+
// Remove <Icon ... /> self-closing tags
17+
.replace(/<Icon\s+[^>]*\/>/g, "")
18+
// Remove tsdoc component tags (opening and closing)
19+
.replace(/<\/?(?:Section|Segment|InlineSection)(?:\s+[^>]*)?>/g, "")
20+
// Convert <NestedTitle ...>content</NestedTitle> to just content
21+
.replace(/<NestedTitle[^>]*>([\s\S]*?)<\/NestedTitle>/g, "$1")
22+
// Remove <div class="tsdoc"> and </div>
23+
.replace(/<div\s+class="tsdoc">/g, "")
24+
.replace(/^<\/div>\s*$/gm, "")
25+
// Merge adjacent <code> tags into one (for type expressions like Input<string>)
26+
.replace(/<\/code><code class="[^"]*">/g, "")
27+
// Convert innermost <code class="...">content</code> → `content`
28+
.replace(/<code class="[^"]*">([^<]*)<\/code>/g, "`$1`")
29+
// Strip remaining outer <code class="..."> and </code> from nested structures
30+
.replace(/<code class="[^"]*">/g, "")
31+
// Convert plain <code>content</code> → `content`
32+
.replace(/<code>([^<]*)<\/code>/g, "`$1`")
33+
.replace(/<\/code>/g, "")
34+
// Remove <p> and </p> wrapper tags
35+
.replace(/<\/?p>/g, "")
36+
// Decode common HTML entities
37+
.replace(/&lt;/g, "<")
38+
.replace(/&gt;/g, ">")
39+
.replace(/&amp;/g, "&")
40+
.replace(/&ldquo;/g, '"')
41+
.replace(/&rdquo;/g, '"')
42+
.replace(/&#123;/g, "{")
43+
.replace(/&#125;/g, "}")
44+
.replace(/&lcub;/g, "{")
45+
.replace(/&rcub;/g, "}")
46+
// Convert <Tabs>/<TabItem> to labeled sections
47+
.replace(/<Tabs>/g, "")
48+
.replace(/<\/Tabs>/g, "")
49+
.replace(/<TabItem\s+label="([^"]*)">/g, "**$1**\n")
50+
.replace(/<\/TabItem>/g, "")
51+
// Collapse 3+ consecutive blank lines to 2
52+
.replace(/\n{3,}/g, "\n\n")
53+
.trim()
54+
);
55+
}

www/sst.config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,28 @@ export default $config({
185185
],
186186
}
187187
: domain,
188+
edge: {
189+
// Rewrite /docs/* to .md when Accept: text/markdown (for AI agents)
190+
viewerRequest: {
191+
injection: [
192+
`var uri = event.request.uri;`,
193+
`var accept = (event.request.headers['accept'] || {}).value || '';`,
194+
`if (uri.startsWith('/docs') && accept.includes('text/markdown') && !/\\.[a-z0-9]+$/i.test(uri)) {`,
195+
` event.request.uri = (uri === '/docs' || uri === '/docs/')`,
196+
` ? '/docs/index.md'`,
197+
` : uri.replace(/\\/$/, '') + '.md';`,
198+
`}`,
199+
].join("\n"),
200+
},
201+
// Fix Content-Type on .md responses (S3 serves them as octet-stream)
202+
viewerResponse: {
203+
injection: [
204+
`if (event.request.uri.endsWith('.md')) {`,
205+
` event.response.headers['content-type'] = { value: 'text/markdown; charset=utf-8' };`,
206+
`}`,
207+
].join("\n"),
208+
},
209+
},
188210
transform: {
189211
cdn: (args) => {
190212
args.origins = $output(args.origins).apply((origins) => [

0 commit comments

Comments
 (0)