Skip to content

Commit f72e56d

Browse files
authored
site: serve markdown when the client asks for it (#623)
* site: add prefersMarkdown Accept-header parser * site: add Accept-header negotiation middleware * site: expand llms.txt with downloads, version, and elevator pitch
1 parent c5e369f commit f72e56d

4 files changed

Lines changed: 158 additions & 3 deletions

File tree

app/llms.txt/route.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
import { getAllPosts } from "@/lib/blog";
22
import { absoluteUrl, siteConfig } from "@/lib/site";
33

4-
export const dynamic = "force-static";
4+
export const revalidate = 300;
5+
6+
async function getStableVersion(): Promise<string | null> {
7+
try {
8+
const res = await fetch(siteConfig.stableManifestUrl, {
9+
next: { revalidate: 300 },
10+
});
11+
if (!res.ok) return null;
12+
const manifest = await res.json();
13+
return manifest.version ?? null;
14+
} catch {
15+
return null;
16+
}
17+
}
518

619
export async function GET() {
7-
const posts = await getAllPosts();
20+
const [posts, version] = await Promise.all([
21+
getAllPosts(),
22+
getStableVersion(),
23+
]);
24+
25+
const downloadsLine = version
26+
? `- [Latest releases](${siteConfig.links.releases}) for macOS, Windows, and Linux. Current stable: v${version}.`
27+
: `- [Latest releases](${siteConfig.links.releases}) for macOS, Windows, and Linux.`;
828

929
const lines = [
1030
`# ${siteConfig.name}`,
1131
"",
12-
`> ${siteConfig.description}`,
32+
"> Native interactive notebooks. Fast to launch, agent ready, humans welcome.",
33+
"",
34+
"nteract is a desktop notebook app for macOS, Windows, and Linux. Open a `.ipynb` file, a kernel starts, you're running code. No browser, no server to manage.",
35+
"",
36+
"## Downloads",
37+
"",
38+
downloadsLine,
39+
`- [Source on GitHub](${siteConfig.links.github})`,
1340
"",
1441
"## Blog",
1542
"",

lib/accept.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { prefersMarkdown } from "@/lib/accept";
4+
5+
describe("prefersMarkdown", () => {
6+
it("returns true for the WebFetch Accept header", () => {
7+
expect(prefersMarkdown("text/markdown, text/html, */*")).toBe(true);
8+
});
9+
10+
it("returns false for a typical Chrome Accept header", () => {
11+
expect(
12+
prefersMarkdown(
13+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
14+
),
15+
).toBe(false);
16+
});
17+
18+
it("returns false for the curl default", () => {
19+
expect(prefersMarkdown("*/*")).toBe(false);
20+
});
21+
22+
it("respects q values when markdown q exceeds html q", () => {
23+
expect(prefersMarkdown("text/html;q=0.5, text/markdown;q=0.9")).toBe(true);
24+
});
25+
26+
it("breaks ties in favor of markdown when both are listed without q", () => {
27+
expect(prefersMarkdown("text/html, text/markdown")).toBe(true);
28+
});
29+
30+
it("returns false for null", () => {
31+
expect(prefersMarkdown(null)).toBe(false);
32+
});
33+
34+
it("returns false for an empty string", () => {
35+
expect(prefersMarkdown("")).toBe(false);
36+
});
37+
38+
it("returns false when only html is listed", () => {
39+
expect(prefersMarkdown("text/html")).toBe(false);
40+
});
41+
42+
it("ignores extra parameters and whitespace", () => {
43+
expect(
44+
prefersMarkdown(" text/markdown ; q=0.95 , text/html ; q=0.5 "),
45+
).toBe(true);
46+
});
47+
});

lib/accept.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
type AcceptEntry = {
2+
type: string;
3+
q: number;
4+
};
5+
6+
function parseAcceptHeader(header: string): AcceptEntry[] {
7+
return header
8+
.split(",")
9+
.map((raw) => raw.trim())
10+
.filter(Boolean)
11+
.map((raw) => {
12+
const [typePart, ...params] = raw.split(";").map((part) => part.trim());
13+
let q = 1;
14+
for (const param of params) {
15+
const [key, value] = param.split("=").map((part) => part.trim());
16+
if (key === "q") {
17+
const parsed = Number.parseFloat(value);
18+
if (!Number.isNaN(parsed)) {
19+
q = parsed;
20+
}
21+
}
22+
}
23+
return { type: typePart.toLowerCase(), q };
24+
});
25+
}
26+
27+
export function prefersMarkdown(
28+
header: string | null | undefined,
29+
): boolean {
30+
if (!header) return false;
31+
32+
const entries = parseAcceptHeader(header);
33+
if (entries.length === 0) return false;
34+
35+
const markdown = entries.find((entry) => entry.type === "text/markdown");
36+
if (!markdown) return false;
37+
38+
const html = entries.find((entry) => entry.type === "text/html");
39+
if (!html) return true;
40+
41+
return markdown.q >= html.q;
42+
}

middleware.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextResponse, type NextRequest } from "next/server";
2+
3+
import { prefersMarkdown } from "@/lib/accept";
4+
5+
export const config = {
6+
matcher: ["/", "/blog", "/blog/:slug"],
7+
};
8+
9+
function rewriteTarget(pathname: string): string | null {
10+
if (pathname === "/" || pathname === "/blog") {
11+
return "/llms.txt";
12+
}
13+
if (pathname.startsWith("/blog/")) {
14+
return `/blog/${pathname.slice("/blog/".length)}/raw.md`;
15+
}
16+
return null;
17+
}
18+
19+
export function middleware(request: NextRequest) {
20+
const wantsMarkdown =
21+
request.method === "GET" &&
22+
prefersMarkdown(request.headers.get("accept"));
23+
24+
const target = wantsMarkdown
25+
? rewriteTarget(request.nextUrl.pathname)
26+
: null;
27+
28+
let response: NextResponse;
29+
if (target) {
30+
const url = request.nextUrl.clone();
31+
url.pathname = target;
32+
response = NextResponse.rewrite(url);
33+
} else {
34+
response = NextResponse.next();
35+
}
36+
37+
response.headers.set("Vary", "Accept");
38+
return response;
39+
}

0 commit comments

Comments
 (0)