diff --git a/source.config.ts b/source.config.ts index 0710dda..f221ede 100644 --- a/source.config.ts +++ b/source.config.ts @@ -16,7 +16,26 @@ export const docs = defineDocs({ docs: { async: isDevelopment, postprocess: { - includeProcessedMarkdown: true, + includeProcessedMarkdown: { + mdxAsPlaceholder: [ + "Accordion", + "AccordionGroup", + "Callout", + "Card", + "CardGroup", + "CodeGroup", + "Frame", + "Info", + "Mermaid", + "Note", + "Step", + "Steps", + "Tab", + "Tabs", + "Tip", + "Warning", + ], + }, }, }, }); diff --git a/src/lib/llms.ts b/src/lib/llms.ts index 6090775..c5bd6a4 100644 --- a/src/lib/llms.ts +++ b/src/lib/llms.ts @@ -51,7 +51,7 @@ export function getLLMSectionConfig(section?: string) { return llmsSectionConfigs[section as LLMSection]; } -export function getPagesForLLMSection(pages: LLMPageLike[], section?: string) { +export function getPagesForLLMSection(pages: TPage[], section?: string) { const config = getLLMSectionConfig(section); if (!config) return pages; diff --git a/src/lib/markdown-alternate.ts b/src/lib/markdown-alternate.ts new file mode 100644 index 0000000..16e96c9 --- /dev/null +++ b/src/lib/markdown-alternate.ts @@ -0,0 +1,40 @@ +import { DOCS_BASE } from "./url-base"; + +const MARKDOWN_ROUTE_PREFIX = `${DOCS_BASE}/llms.mdx/docs`; + +function normalizeDocsPath(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + return normalized || DOCS_BASE; +} + +export function buildMarkdownAlternatePath(pathname: string): string { + const normalized = normalizeDocsPath(pathname); + if (normalized === DOCS_BASE) return MARKDOWN_ROUTE_PREFIX; + return `${normalized}.md`; +} + +export function buildHtmlPathFromMarkdownRoute(slugs: string[]): string { + if (slugs.length === 0) return DOCS_BASE; + return `${DOCS_BASE}/${slugs.join("/")}`; +} + +export function appendHeaderValue(headers: Headers, name: string, value: string) { + const current = headers.get(name); + if (!current) { + headers.set(name, value); + return; + } + + const values = current.split(",").map((entry) => entry.trim().toLowerCase()); + if (!values.includes(value.toLowerCase())) { + headers.set(name, `${current}, ${value}`); + } +} + +export function appendVaryAccept(headers: Headers) { + appendHeaderValue(headers, "Vary", "Accept"); +} + +export function buildMarkdownAlternateLinkHeader(pathname: string): string { + return `<${buildMarkdownAlternatePath(pathname)}>; rel="alternate"; type="text/markdown"`; +} diff --git a/src/lib/source.ts b/src/lib/source.ts index a6ba994..4e11fe6 100644 --- a/src/lib/source.ts +++ b/src/lib/source.ts @@ -1,4 +1,8 @@ import { InferPageType, loader } from "fumadocs-core/source"; +import { + renderPlaceholder, + type PlaceholderData, +} from "fumadocs-core/mdx-plugins/remark-llms.runtime"; import { docs } from "fumadocs-mdx:collections/server"; import { createElement, type SVGProps } from "react"; import { @@ -134,17 +138,76 @@ export const source = loader({ }, }); +function getStringAttribute(attributes: Record, key: string): string | undefined { + const value = attributes[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function joinMarkdownBlocks(blocks: Array) { + return blocks + .map((block) => block?.trim()) + .filter(Boolean) + .join("\n\n"); +} + +function renderMarkdownBlock(blocks: Array) { + const block = joinMarkdownBlocks(blocks); + return block ? `${block}\n\n` : ""; +} + +function renderCallout(label: string, data: PlaceholderData) { + const title = getStringAttribute(data.attributes, "title") ?? label; + return renderMarkdownBlock([`> **${title}**`, data.children]); +} + +const markdownPlaceholderRenderers: Record string> = { + Accordion: (data) => + renderMarkdownBlock([ + `## ${getStringAttribute(data.attributes, "title") ?? "Details"}`, + data.children, + ]), + AccordionGroup: (data) => data.children, + Callout: (data) => renderCallout("Callout", data), + Card: (data) => + renderMarkdownBlock([ + `## ${getStringAttribute(data.attributes, "title") ?? "Link"}`, + getStringAttribute(data.attributes, "href"), + data.children, + ]), + CardGroup: (data) => data.children, + CodeGroup: (data) => data.children, + Frame: (data) => data.children, + Info: (data) => renderCallout("Info", data), + Mermaid: (data) => `\`\`\`mermaid\n${data.children.trim()}\n\`\`\`\n\n`, + Note: (data) => renderCallout("Note", data), + Step: (data) => + renderMarkdownBlock([ + `## ${getStringAttribute(data.attributes, "title") ?? "Step"}`, + data.children, + ]), + Steps: (data) => data.children, + Tab: (data) => + renderMarkdownBlock([ + `## ${getStringAttribute(data.attributes, "title") ?? "Tab"}`, + data.children, + ]), + Tabs: (data) => data.children, + Tip: (data) => renderCallout("Tip", data), + Warning: (data) => renderCallout("Warning", data), +}; + export async function getPageMarkdownText( page: InferPageType, _type: "raw" | "processed" = "processed", ) { - return page.data.getText("processed"); + const processed = await renderPlaceholder( + await page.data.getText("processed"), + markdownPlaceholderRenderers, + ); + + return joinMarkdownBlocks([`# ${page.data.title}`, page.data.description, processed]); } export async function getLLMText(page: InferPageType) { - const processed = await getPageMarkdownText(page); - - return `# ${page.data.title} - -${processed}`; + return getPageMarkdownText(page); } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f9050ae..a611e4e 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -19,8 +19,10 @@ import { Route as SplatRouteImport } from './routes/$' import { Route as IndexRouteImport } from './routes/index' import { Route as SdkIndexRouteImport } from './routes/sdk/index' import { Route as SdkSplatRouteImport } from './routes/sdk/$' +import { Route as LlmsDotmdxDocsRouteImport } from './routes/llms[.]mdx.docs' import { Route as ApiSearchRouteImport } from './routes/api/search' import { Route as ApiFeedbackRouteImport } from './routes/api/feedback' +import { Route as LlmsDotmdxDocsIndexRouteImport } from './routes/llms[.]mdx.docs.index' import { Route as LlmsDotmdxDocsSplatRouteImport } from './routes/llms[.]mdx.docs.$' import { Route as ApiRawSplatRouteImport } from './routes/api/raw/$' @@ -76,6 +78,11 @@ const SdkSplatRoute = SdkSplatRouteImport.update({ path: '/sdk/$', getParentRoute: () => rootRouteImport, } as any) +const LlmsDotmdxDocsRoute = LlmsDotmdxDocsRouteImport.update({ + id: '/llms.mdx/docs', + path: '/llms.mdx/docs', + getParentRoute: () => rootRouteImport, +} as any) const ApiSearchRoute = ApiSearchRouteImport.update({ id: '/api/search', path: '/api/search', @@ -86,10 +93,15 @@ const ApiFeedbackRoute = ApiFeedbackRouteImport.update({ path: '/api/feedback', getParentRoute: () => rootRouteImport, } as any) +const LlmsDotmdxDocsIndexRoute = LlmsDotmdxDocsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LlmsDotmdxDocsRoute, +} as any) const LlmsDotmdxDocsSplatRoute = LlmsDotmdxDocsSplatRouteImport.update({ - id: '/llms.mdx/docs/$', - path: '/llms.mdx/docs/$', - getParentRoute: () => rootRouteImport, + id: '/$', + path: '/$', + getParentRoute: () => LlmsDotmdxDocsRoute, } as any) const ApiRawSplatRoute = ApiRawSplatRouteImport.update({ id: '/api/raw/$', @@ -108,10 +120,12 @@ export interface FileRoutesByFullPath { '/sitemap.xml': typeof SitemapDotxmlRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute + '/llms.mdx/docs': typeof LlmsDotmdxDocsRouteWithChildren '/sdk/$': typeof SdkSplatRoute '/sdk/': typeof SdkIndexRoute '/api/raw/$': typeof ApiRawSplatRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute + '/llms.mdx/docs/': typeof LlmsDotmdxDocsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -128,6 +142,7 @@ export interface FileRoutesByTo { '/sdk': typeof SdkIndexRoute '/api/raw/$': typeof ApiRawSplatRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute + '/llms.mdx/docs': typeof LlmsDotmdxDocsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -141,10 +156,12 @@ export interface FileRoutesById { '/sitemap.xml': typeof SitemapDotxmlRoute '/api/feedback': typeof ApiFeedbackRoute '/api/search': typeof ApiSearchRoute + '/llms.mdx/docs': typeof LlmsDotmdxDocsRouteWithChildren '/sdk/$': typeof SdkSplatRoute '/sdk/': typeof SdkIndexRoute '/api/raw/$': typeof ApiRawSplatRoute '/llms.mdx/docs/$': typeof LlmsDotmdxDocsSplatRoute + '/llms.mdx/docs/': typeof LlmsDotmdxDocsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -159,10 +176,12 @@ export interface FileRouteTypes { | '/sitemap.xml' | '/api/feedback' | '/api/search' + | '/llms.mdx/docs' | '/sdk/$' | '/sdk/' | '/api/raw/$' | '/llms.mdx/docs/$' + | '/llms.mdx/docs/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -179,6 +198,7 @@ export interface FileRouteTypes { | '/sdk' | '/api/raw/$' | '/llms.mdx/docs/$' + | '/llms.mdx/docs' id: | '__root__' | '/' @@ -191,10 +211,12 @@ export interface FileRouteTypes { | '/sitemap.xml' | '/api/feedback' | '/api/search' + | '/llms.mdx/docs' | '/sdk/$' | '/sdk/' | '/api/raw/$' | '/llms.mdx/docs/$' + | '/llms.mdx/docs/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -208,10 +230,10 @@ export interface RootRouteChildren { SitemapDotxmlRoute: typeof SitemapDotxmlRoute ApiFeedbackRoute: typeof ApiFeedbackRoute ApiSearchRoute: typeof ApiSearchRoute + LlmsDotmdxDocsRoute: typeof LlmsDotmdxDocsRouteWithChildren SdkSplatRoute: typeof SdkSplatRoute SdkIndexRoute: typeof SdkIndexRoute ApiRawSplatRoute: typeof ApiRawSplatRoute - LlmsDotmdxDocsSplatRoute: typeof LlmsDotmdxDocsSplatRoute } declare module '@tanstack/react-router' { @@ -286,6 +308,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SdkSplatRouteImport parentRoute: typeof rootRouteImport } + '/llms.mdx/docs': { + id: '/llms.mdx/docs' + path: '/llms.mdx/docs' + fullPath: '/llms.mdx/docs' + preLoaderRoute: typeof LlmsDotmdxDocsRouteImport + parentRoute: typeof rootRouteImport + } '/api/search': { id: '/api/search' path: '/api/search' @@ -300,12 +329,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiFeedbackRouteImport parentRoute: typeof rootRouteImport } + '/llms.mdx/docs/': { + id: '/llms.mdx/docs/' + path: '/' + fullPath: '/llms.mdx/docs/' + preLoaderRoute: typeof LlmsDotmdxDocsIndexRouteImport + parentRoute: typeof LlmsDotmdxDocsRoute + } '/llms.mdx/docs/$': { id: '/llms.mdx/docs/$' - path: '/llms.mdx/docs/$' + path: '/$' fullPath: '/llms.mdx/docs/$' preLoaderRoute: typeof LlmsDotmdxDocsSplatRouteImport - parentRoute: typeof rootRouteImport + parentRoute: typeof LlmsDotmdxDocsRoute } '/api/raw/$': { id: '/api/raw/$' @@ -317,6 +353,20 @@ declare module '@tanstack/react-router' { } } +interface LlmsDotmdxDocsRouteChildren { + LlmsDotmdxDocsSplatRoute: typeof LlmsDotmdxDocsSplatRoute + LlmsDotmdxDocsIndexRoute: typeof LlmsDotmdxDocsIndexRoute +} + +const LlmsDotmdxDocsRouteChildren: LlmsDotmdxDocsRouteChildren = { + LlmsDotmdxDocsSplatRoute: LlmsDotmdxDocsSplatRoute, + LlmsDotmdxDocsIndexRoute: LlmsDotmdxDocsIndexRoute, +} + +const LlmsDotmdxDocsRouteWithChildren = LlmsDotmdxDocsRoute._addFileChildren( + LlmsDotmdxDocsRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SplatRoute: SplatRoute, @@ -329,10 +379,10 @@ const rootRouteChildren: RootRouteChildren = { SitemapDotxmlRoute: SitemapDotxmlRoute, ApiFeedbackRoute: ApiFeedbackRoute, ApiSearchRoute: ApiSearchRoute, + LlmsDotmdxDocsRoute: LlmsDotmdxDocsRouteWithChildren, SdkSplatRoute: SdkSplatRoute, SdkIndexRoute: SdkIndexRoute, ApiRawSplatRoute: ApiRawSplatRoute, - LlmsDotmdxDocsSplatRoute: LlmsDotmdxDocsSplatRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/$.tsx b/src/routes/$.tsx index b1f184c..2f8aff7 100644 --- a/src/routes/$.tsx +++ b/src/routes/$.tsx @@ -23,6 +23,7 @@ import { } from "@/lib/metadata"; import { getSdkContextFromRouterPath, SDK_CONTEXT_SLUGS } from "@/lib/sdk-navigation"; import { buildDocsApiPath } from "@/lib/url-base"; +import { buildMarkdownAlternatePath } from "@/lib/markdown-alternate"; import { staticCacheMiddleware } from "@/lib/static-cache-middleware"; @@ -68,7 +69,18 @@ export const Route = createFileRoute("/$")({ { name: "twitter:description", content: pageDescription }, { name: "twitter:image", content: DEFAULT_SOCIAL_IMAGE_URL }, ], - links: [{ rel: "canonical", href: canonicalUrl }], + links: [ + { rel: "canonical", href: canonicalUrl }, + ...(loaderData + ? [ + { + rel: "alternate", + type: "text/markdown", + href: buildMarkdownAlternatePath(loaderData.url), + }, + ] + : []), + ], }; }, }); @@ -138,8 +150,8 @@ const clientLoader = browserCollections.docs.createClientLoader({ {frontmatter.title} {frontmatter.description}
- - + +
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 5d24b0b..ecf091e 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -22,6 +22,7 @@ import { import { normalizeDocsInternalHref } from "@/lib/docs-url"; import { resolveSdkAwareDocsHref } from "@/lib/sdk-navigation"; import { buildDocsPath, toRouterPath } from "@/lib/url-base"; +import { buildMarkdownAlternatePath } from "@/lib/markdown-alternate"; export const Route = createFileRoute("/")({ component: DocsOverview, @@ -47,7 +48,14 @@ export const Route = createFileRoute("/")({ { name: "twitter:description", content: description }, { name: "twitter:image", content: DEFAULT_SOCIAL_IMAGE_URL }, ], - links: [{ rel: "canonical", href: canonicalUrl }], + links: [ + { rel: "canonical", href: canonicalUrl }, + { + rel: "alternate", + type: "text/markdown", + href: buildMarkdownAlternatePath("/docs"), + }, + ], }; }, }); @@ -107,7 +115,8 @@ const docsCards: DocCard[] = [ }, { title: "Superwall Agents", - description: "Analyze experiments, automate reports, connect tools, and work with hosted machines.", + description: + "Analyze experiments, automate reports, connect tools, and work with hosted machines.", href: buildDocsPath("agents"), icon: , }, diff --git a/src/routes/llms[.]mdx.docs.$.ts b/src/routes/llms[.]mdx.docs.$.ts index 737e36f..357a583 100644 --- a/src/routes/llms[.]mdx.docs.$.ts +++ b/src/routes/llms[.]mdx.docs.$.ts @@ -1,21 +1,32 @@ import { createFileRoute, notFound } from "@tanstack/react-router"; import { getPageMarkdownText, source } from "@/lib/source"; +import { buildLLMSummaryText } from "@/lib/llms"; +import { buildHtmlPathFromMarkdownRoute } from "@/lib/markdown-alternate"; export const Route = createFileRoute("/llms.mdx/docs/$")({ server: { handlers: { GET: async ({ params }) => { const slugs = params._splat?.split("/") ?? []; - const page = source.getPage(slugs); - if (!page) throw notFound(); + const body = + slugs.length === 0 ? await buildLLMSummaryText() : await getMarkdownForPage(slugs); - return new Response(await getPageMarkdownText(page), { + return new Response(body, { headers: { - "Content-Type": "text/markdown", + "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", + Vary: "Accept", + Link: `<${buildHtmlPathFromMarkdownRoute(slugs)}>; rel="canonical"; type="text/html"`, }, }); }, }, }, }); + +async function getMarkdownForPage(slugs: string[]) { + const page = source.getPage(slugs); + if (!page) throw notFound(); + + return getPageMarkdownText(page); +} diff --git a/src/routes/llms[.]mdx.docs.index.ts b/src/routes/llms[.]mdx.docs.index.ts new file mode 100644 index 0000000..bf072a0 --- /dev/null +++ b/src/routes/llms[.]mdx.docs.index.ts @@ -0,0 +1,20 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { buildLLMSummaryText } from "@/lib/llms"; +import { DOCS_BASE } from "@/lib/url-base"; + +export const Route = createFileRoute("/llms.mdx/docs/")({ + server: { + handlers: { + GET: async () => { + return new Response(await buildLLMSummaryText(), { + headers: { + "Content-Type": "text/markdown; charset=utf-8", + "Access-Control-Allow-Origin": "*", + Vary: "Accept", + Link: `<${DOCS_BASE}>; rel="canonical"; type="text/html"`, + }, + }); + }, + }, + }, +}); diff --git a/src/routes/llms[.]mdx.docs.ts b/src/routes/llms[.]mdx.docs.ts new file mode 100644 index 0000000..5472217 --- /dev/null +++ b/src/routes/llms[.]mdx.docs.ts @@ -0,0 +1,3 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/llms.mdx/docs")({}); diff --git a/src/start.test.ts b/src/start.test.ts index 040c195..a2a858e 100644 --- a/src/start.test.ts +++ b/src/start.test.ts @@ -1,6 +1,12 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { resolveLegacyRedirect } from "./start"; +import { negotiateDocsRepresentation, resolveLegacyRedirect, resolveLLMPath } from "./start"; + +function docsRequest(accept?: string, path = "/docs/dashboard") { + return new Request(`https://example.com${path}`, { + headers: accept === undefined ? undefined : { Accept: accept }, + }); +} describe("resolveLegacyRedirect", () => { test("does not redirect sdk placeholder routes", () => { @@ -20,3 +26,58 @@ describe("resolveLegacyRedirect", () => { ); }); }); + +describe("negotiateDocsRepresentation", () => { + test("selects markdown when text/markdown is preferred", () => { + assert.equal(negotiateDocsRepresentation(docsRequest("text/markdown")), "text/markdown"); + }); + + test("preserves legacy markdown media types", () => { + assert.equal(negotiateDocsRepresentation(docsRequest("text/plain")), "text/markdown"); + assert.equal(negotiateDocsRepresentation(docsRequest("text/x-markdown")), "text/markdown"); + }); + + test("selects html when html has a higher q-value", () => { + assert.equal( + negotiateDocsRepresentation(docsRequest("text/html;q=1,text/markdown;q=0.5")), + "text/html", + ); + }); + + test("respects q=0 for markdown", () => { + assert.equal( + negotiateDocsRepresentation(docsRequest("text/markdown;q=0,text/html;q=1")), + "text/html", + ); + }); + + test("defaults missing Accept and wildcards to html", () => { + assert.equal(negotiateDocsRepresentation(docsRequest()), "text/html"); + assert.equal(negotiateDocsRepresentation(docsRequest("*/*")), "text/html"); + }); + + test("returns undefined for unsupported explicit types", () => { + assert.equal( + negotiateDocsRepresentation(docsRequest("application/x-content-negotiation-probe")), + undefined, + ); + }); +}); + +describe("resolveLLMPath", () => { + test("rewrites canonical docs paths only when markdown wins negotiation", () => { + assert.equal(resolveLLMPath(docsRequest("text/markdown")), "/docs/llms.mdx/docs/dashboard"); + assert.equal(resolveLLMPath(docsRequest("text/html;q=1,text/markdown;q=0.5")), undefined); + }); + + test("rewrites direct markdown suffixes regardless of Accept", () => { + assert.equal( + resolveLLMPath(docsRequest("text/html", "/docs/dashboard.md")), + "/docs/llms.mdx/docs/dashboard", + ); + assert.equal( + resolveLLMPath(docsRequest("text/html", "/docs/dashboard.mdx")), + "/docs/llms.mdx/docs/dashboard", + ); + }); +}); diff --git a/src/start.ts b/src/start.ts index 9171d03..9792180 100644 --- a/src/start.ts +++ b/src/start.ts @@ -1,8 +1,13 @@ import { createMiddleware, createStart } from "@tanstack/react-start"; -import { isMarkdownPreferred, rewritePath } from "fumadocs-core/negotiation"; +import { getNegotiator, rewritePath } from "fumadocs-core/negotiation"; import { redirect } from "@tanstack/react-router"; import { buildRedirectRouteRules } from "./lib/redirect-route-rules"; import { DOCS_BASE } from "./lib/url-base"; +import { + appendHeaderValue, + appendVaryAccept, + buildMarkdownAlternateLinkHeader, +} from "./lib/markdown-alternate"; const { rewrite: rewriteLLMMarkdown } = rewritePath( `${DOCS_BASE}{/*path}.md`, @@ -23,6 +28,9 @@ const redirectBypassPrefixes = [ `${DOCS_BASE}/assets`, ]; const staticAssetPathPattern = /\.[a-z0-9]{2,8}$/i; +const markdownMediaTypes = ["text/markdown", "text/plain", "text/x-markdown"] as const; +const negotiatedMediaTypes = ["text/html", ...markdownMediaTypes] as const; +type DocsRepresentation = "text/html" | "text/markdown"; function normalizePathname(pathname: string): string { const normalized = pathname.replace(/\/+$/, ""); @@ -70,6 +78,39 @@ function shouldBypassLLMRewrite(pathname: string): boolean { return /\.(?!mdx?$)[a-z0-9]{2,8}$/i.test(pathname); } +export function negotiateDocsRepresentation(request: Request): DocsRepresentation | undefined { + const mediaType = getNegotiator(request).mediaType([...negotiatedMediaTypes]); + if (!mediaType) return undefined; + if (mediaType === "text/html") return "text/html"; + return "text/markdown"; +} + +function buildNotAcceptableResponse(): Response { + return new Response("Not Acceptable", { + status: 406, + headers: { + "Content-Type": "text/plain; charset=utf-8", + Vary: "Accept", + }, + }); +} + +function withDocsNegotiationHeaders(response: Response, pathname: string): Response { + const headers = new Headers(response.headers); + appendVaryAccept(headers); + + const contentType = headers.get("Content-Type"); + if (contentType?.toLowerCase().startsWith("text/html")) { + appendHeaderValue(headers, "Link", buildMarkdownAlternateLinkHeader(pathname)); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + export function resolveLLMPath(request: Request): string | undefined { const url = new URL(request.url); if (shouldBypassLLMRewrite(url.pathname)) return undefined; @@ -77,7 +118,7 @@ export function resolveLLMPath(request: Request): string | undefined { const directRewrite = rewriteLLMMarkdown(url.pathname) || rewriteLLMMdx(url.pathname); if (directRewrite) return directRewrite; - if (!isMarkdownPreferred(request)) return undefined; + if (negotiateDocsRepresentation(request) !== "text/markdown") return undefined; return rewriteLLMPreferred(url.pathname) || undefined; } @@ -112,12 +153,14 @@ const legacyRedirectMiddleware = createMiddleware().server(({ next, request }) = return next(); }); -const llmMiddleware = createMiddleware().server(({ next, request }) => { +const llmMiddleware = createMiddleware().server(async ({ next, request }) => { const url = new URL(request.url); let destination: string | undefined; + let representation: DocsRepresentation | undefined; try { destination = resolveLLMPath(request); + representation = negotiateDocsRepresentation(request); } catch { return next(); } @@ -126,10 +169,23 @@ const llmMiddleware = createMiddleware().server(({ next, request }) => { throw redirect({ href: withRequestSearch(url, destination).toString(), statusCode: 307, + headers: { + Vary: "Accept", + }, }); } - return next(); + if (!shouldBypassLLMRewrite(url.pathname) && !representation) { + return buildNotAcceptableResponse(); + } + + const result = await next(); + return { + ...result, + response: !shouldBypassLLMRewrite(url.pathname) + ? withDocsNegotiationHeaders(result.response, url.pathname) + : result.response, + }; }); export const startInstance = createStart(() => {