Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/astro/src/api-reference.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
buildApiReferenceHtmlDocumentAsync,
createApiReferenceOperationMarkdownResponse,
resolveApiReferenceConfig,
resolveApiReferenceVersionFromPathname,
} from "@farming-labs/docs/server";
import type { DocsConfig } from "@farming-labs/docs";
import { resolveDocsMetadataBaseUrl, type DocsConfig } from "@farming-labs/docs";

export function createAstroApiReference(config: DocsConfig & Record<string, any>) {
return async (context?: { url?: URL; request?: Request }) => {
Expand All @@ -15,6 +16,18 @@ export function createAstroApiReference(config: DocsConfig & Record<string, any>
const rootDir = typeof config.rootDir === "string" ? config.rootDir : process.cwd();
const requestUrl =
context?.url ?? (context?.request ? new URL(context.request.url) : undefined);
if (context?.request && requestUrl) {
const markdownResponse = await createApiReferenceOperationMarkdownResponse(config, {
request: context.request,
framework: "astro",
rootDir,
baseUrl: requestUrl.origin,
origin: resolveDocsMetadataBaseUrl(config) || requestUrl.origin,
sitemap: config.sitemap,
okf: config.agent?.okf,
});
if (markdownResponse) return markdownResponse;
}
const version = requestUrl
? resolveApiReferenceVersionFromPathname(config.apiReference, requestUrl.pathname)
: undefined;
Expand Down
20 changes: 20 additions & 0 deletions packages/astro/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import type { DocsAgentTraceEventInput, DocsAskAIMcpConfig, DocsConfig } from "@
import {
buildApiReferenceOpenApiDocumentAsync,
buildApiReferenceOperationPagesAsync,
createApiReferenceOperationMarkdownResponse,
createDocsMcpHttpHandler,
readDocsSitemapManifest,
resolveApiReferenceConfig,
Expand Down Expand Up @@ -1310,6 +1311,25 @@ export function createDocsServer(config: Record<string, any> = {}): DocsServer {
});
if (robotsResponse) return robotsResponse;

const operationMarkdownResponse = await createApiReferenceOperationMarkdownResponse(
config as DocsConfig,
{
request: context.request,
framework: "astro",
rootDir,
baseUrl: url.origin,
origin: markdownMetadataBaseUrl || url.origin,
locale: ctx.locale,
apiRoute: discoveryApiRoute,
pages: getSearchIndex(ctx),
operationPages: () =>
getApiOperationSearchPages(ctx, markdownMetadataBaseUrl || url.origin),
sitemap: config.sitemap,
okf: config.agent?.okf,
},
);
if (operationMarkdownResponse) return operationMarkdownResponse;

const markdownRequest = resolveDocsMarkdownRequest(entry, url, context.request, {
apiRoute: discoveryApiRoute,
});
Expand Down
42 changes: 42 additions & 0 deletions packages/docs/src/api-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
buildApiReferenceOpenApiDocument,
buildApiReferenceOpenApiDocumentAsync,
buildApiReferenceOperationPagesAsync,
createApiReferenceOperationMarkdownResponse,
DEFAULT_API_REFERENCE_OPENAPI_ROUTE,
OPENAPI_SPEC_FETCH_TIMEOUT_MS,
OPENAPI_SPEC_MAX_BYTES,
Expand Down Expand Up @@ -1268,6 +1269,47 @@ describe("buildApiReferenceOperationPagesAsync", () => {
expect(pages[1]?.content).toContain("`GET /widgets`");
expect(pages[1]?.content).toContain("`limit` (query, optional");
expect(pages[1]?.content).toContain("### 200");

const operationUrl = pages[1]?.url;
expect(operationUrl).toBeTruthy();
const markdownResponse = await createApiReferenceOperationMarkdownResponse(config, {
request: new Request(`https://docs.example.com${operationUrl}.md`),
framework: "astro",
rootDir,
baseUrl: "https://docs.example.com",
locale: "fr",
});
expect(markdownResponse?.status).toBe(200);
expect(markdownResponse?.headers.get("content-type")).toContain("text/markdown");
expect(markdownResponse?.headers.get("content-location")).toBe(
`https://docs.example.com${operationUrl}.md`,
);
expect(markdownResponse?.headers.get("link")).toContain(
`<https://docs.example.com${operationUrl}>; rel="canonical"`,
);
expect(markdownResponse?.headers.get("etag")).toBeTruthy();
const markdown = await markdownResponse?.text();
expect(markdown).toContain(`canonical_url: "https://docs.example.com${operationUrl}"`);
expect(markdown).toContain(`markdown_url: "https://docs.example.com${operationUrl}.md"`);
expect(markdown).toContain("`GET /widgets`");

const sharedApiResponse = await createApiReferenceOperationMarkdownResponse(config, {
request: new Request(
`https://docs.example.com/api/docs?format=markdown&path=${encodeURIComponent(operationUrl ?? "")}`,
),
framework: "astro",
rootDir,
baseUrl: "https://docs.example.com",
});
expect(sharedApiResponse?.status).toBe(200);

await expect(
createApiReferenceOperationMarkdownResponse(config, {
request: new Request(`https://docs.example.com${operationUrl}`),
framework: "astro",
rootDir,
}),
).resolves.toBeNull();
});

it("returns no projected pages when API reference is disabled", async () => {
Expand Down
133 changes: 133 additions & 0 deletions packages/docs/src/api-reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { basename, extname, isAbsolute, join, relative, resolve, sep } from "nod
import { fileURLToPath, pathToFileURL } from "node:url";
import { getHtmlDocument } from "@scalar/core/libs/html-rendering";
import { parse as parseYaml } from "yaml";
import {
acceptsDocsMarkdown,
createDocsMarkdownResponse,
detectDocsMarkdownAgentRequest,
hasDocsMarkdownSignatureAgent,
renderDocsMarkdownDocument,
toDocsMarkdownUrl,
} from "./agent.js";
import { assertValidOpenApiContract } from "./openapi-contract.js";
import { applyOpenApiOverlayDocuments } from "./openapi-overlays.js";
import type { OpenApiOverlayDocumentSource } from "./openapi-overlays.js";
Expand All @@ -21,7 +29,9 @@ import type {
ApiReferenceVersionConfig,
DocsConfig,
DocsOpenApiMcpConfig,
DocsOkfConfig,
DocsSearchSourcePage,
DocsSitemapConfig,
DocsTheme,
} from "./types.js";

Expand Down Expand Up @@ -105,6 +115,22 @@ export interface BuildApiReferenceOperationPagesOptions extends Omit<
locale?: string;
}

export interface CreateApiReferenceOperationMarkdownResponseOptions extends BuildApiReferenceOperationPagesOptions {
request: Request;
/** Shared Docs API pathname used by query-form Markdown requests. @default "/api/docs" */
apiRoute?: string;
/** Public metadata origin. Defaults to `baseUrl`, then the request origin. */
origin?: string;
/** Authored pages included in Markdown recovery suggestions. */
pages?: DocsSearchSourcePage[];
/** Reuse a cached operation projection when the adapter already maintains one. */
operationPages?:
| DocsSearchSourcePage[]
| (() => DocsSearchSourcePage[] | Promise<DocsSearchSourcePage[]>);
sitemap?: boolean | DocsSitemapConfig;
okf?: boolean | DocsOkfConfig;
}

interface BuildApiReferenceHtmlOptions extends BuildApiReferenceOptions {
title?: string;
}
Expand Down Expand Up @@ -956,6 +982,113 @@ export async function buildApiReferenceOperationPagesAsync(
return pagesByVersion.flat();
}

function normalizeApiReferenceMarkdownPath(value: string): string {
const trimmed = value.trim().replace(/\.md$/i, "");
if (!trimmed) return "/";
return `/${trimmed.replace(/^\/+|\/+$/g, "")}`.replace(/\/{2,}/g, "/");
}

function isApiReferenceOperationPath(pathname: string, apiReferencePath: string): boolean {
const basePath = `/${normalizePathSegment(apiReferencePath)}`;
if (!pathname.startsWith(`${basePath}/`)) return false;
const segments = pathname.slice(basePath.length + 1).split("/");
return (
(segments.length === 2 && segments[0] === "operations" && Boolean(segments[1])) ||
(segments.length === 3 && segments[1] === "operations" && Boolean(segments[0] && segments[2]))
);
}

function resolveApiReferenceOperationMarkdownPath(
request: Request,
apiReferencePath: string,
apiRoute = "/api/docs",
): string | null {
if (request.method !== "GET" && request.method !== "HEAD") return null;

const url = new URL(request.url);
const pathname = normalizeApiReferenceMarkdownPath(url.pathname);
const normalizedApiRoute = normalizeApiReferenceMarkdownPath(apiRoute);
if (pathname === normalizedApiRoute && url.searchParams.get("format")?.trim() === "markdown") {
const requestedPath = normalizeApiReferenceMarkdownPath(
url.searchParams.get("path")?.trim() ?? "",
);
return isApiReferenceOperationPath(requestedPath, apiReferencePath) ? requestedPath : null;
}

if (url.pathname.toLowerCase().endsWith(".md")) {
return isApiReferenceOperationPath(pathname, apiReferencePath) ? pathname : null;
}

const requestsMarkdown =
acceptsDocsMarkdown(request) ||
hasDocsMarkdownSignatureAgent(request) ||
detectDocsMarkdownAgentRequest(request).detected;
return requestsMarkdown && isApiReferenceOperationPath(pathname, apiReferencePath)
? pathname
: null;
}

/**
* Resolve a public per-operation Markdown request using the same generated projection as search,
* Ask AI, and MCP. Returns `null` when the request is not an API operation Markdown route.
*/
export async function createApiReferenceOperationMarkdownResponse(
config: DocsConfig,
options: CreateApiReferenceOperationMarkdownResponseOptions,
): Promise<Response | null> {
const apiReference = resolveApiReferenceConfig(config.apiReference);
if (!apiReference.enabled) return null;

const requestedPath = resolveApiReferenceOperationMarkdownPath(
options.request,
apiReference.path,
options.apiRoute,
);
if (!requestedPath) return null;

const requestUrl = new URL(options.request.url);
const origin = options.origin ?? options.baseUrl ?? requestUrl.origin;
const operationPages = options.operationPages
? await (typeof options.operationPages === "function"
? options.operationPages()
: options.operationPages)
: await buildApiReferenceOperationPagesAsync(config, {
framework: options.framework,
rootDir: options.rootDir,
baseUrl: options.baseUrl ?? requestUrl.origin,
locale: options.locale,
});
const page = operationPages.find(
(candidate) => normalizeApiReferenceMarkdownPath(candidate.url) === requestedPath,
);
const canonicalPath = page?.canonicalUrl ?? page?.url ?? requestedPath;
const canonicalUrl = new URL(canonicalPath, origin).toString();
const contentLocation = new URL(toDocsMarkdownUrl(page?.url ?? requestedPath), origin).toString();
const relativeRequestedPath = requestedPath
.slice(`/${apiReference.path}`.length)
.replace(/^\/+/, "");

return createDocsMarkdownResponse({
request: options.request,
apiRoute: options.apiRoute,
document: page
? renderDocsMarkdownDocument(page, {
origin,
sitemap: options.sitemap,
okf: options.okf,
})
: null,
entry: apiReference.path,
requestedPath: relativeRequestedPath,
origin,
locale: options.locale,
canonicalUrl,
contentLocation,
pages: [...(options.pages ?? []), ...operationPages],
sitemap: options.sitemap,
});
}

function renderApiReferenceOperationMarkdown(
operation: NormalizedOpenApiOperation,
metadata: { apiTitle: string; apiVersion: string; specificationVersion: string },
Expand Down
2 changes: 2 additions & 0 deletions packages/docs/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
buildApiReferenceOpenApiDocument,
buildApiReferenceOpenApiDocumentAsync,
buildApiReferenceOperationPagesAsync,
createApiReferenceOperationMarkdownResponse,
buildApiReferenceHtmlDocument,
buildApiReferenceHtmlDocumentAsync,
buildApiReferencePageTitle,
Expand All @@ -34,6 +35,7 @@ export type {
ApiReferenceRoute,
BuildApiReferenceOptions,
BuildApiReferenceOperationPagesOptions,
CreateApiReferenceOperationMarkdownResponseOptions,
ResolvedApiReferenceConfig,
ResolvedApiReferenceVersion,
} from "./api-reference.js";
Expand Down
15 changes: 14 additions & 1 deletion packages/farmjs/src/api-reference.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
buildApiReferenceHtmlDocumentAsync,
createApiReferenceOperationMarkdownResponse,
resolveApiReferenceConfig,
resolveApiReferenceVersionFromPathname,
} from "@farming-labs/docs/server";
import type { DocsConfig } from "@farming-labs/docs";
import { resolveDocsMetadataBaseUrl, type DocsConfig } from "@farming-labs/docs";

export function createFarmjsApiReference(config: DocsConfig & Record<string, any>) {
return async (event?: { request?: Request; url?: URL } | Request) => {
Expand All @@ -18,6 +19,18 @@ export function createFarmjsApiReference(config: DocsConfig & Record<string, any
event instanceof Request
? new URL(event.url)
: (event?.url ?? (request ? new URL(request.url) : undefined));
if (request && requestUrl) {
const markdownResponse = await createApiReferenceOperationMarkdownResponse(config, {
request,
framework: "farmjs",
rootDir,
baseUrl: requestUrl.origin,
origin: resolveDocsMetadataBaseUrl(config) || requestUrl.origin,
sitemap: config.sitemap,
okf: config.agent?.okf,
});
if (markdownResponse) return markdownResponse;
}
const version = requestUrl
? resolveApiReferenceVersionFromPathname(config.apiReference, requestUrl.pathname)
: undefined;
Expand Down
21 changes: 21 additions & 0 deletions packages/farmjs/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type { DocsAgentTraceEventInput, DocsAskAIMcpConfig, DocsConfig } from "@
import {
buildApiReferenceOpenApiDocumentAsync,
buildApiReferenceOperationPagesAsync,
createApiReferenceOperationMarkdownResponse,
createDocsMcpHttpHandler,
readDocsSitemapManifest,
resolveApiReferenceConfig,
Expand Down Expand Up @@ -1505,6 +1506,25 @@ export function createDocsServer(config: Record<string, any>): DocsServer {
});
if (robotsResponse) return robotsResponse;

const operationMarkdownResponse = await createApiReferenceOperationMarkdownResponse(
config as DocsConfig,
{
request: event.request,
framework: "farmjs",
rootDir,
baseUrl: url.origin,
origin: markdownMetadataBaseUrl || url.origin,
locale: ctx.locale,
apiRoute: discoveryApiRoute,
pages: getSearchIndex(ctx),
operationPages: () =>
getApiOperationSearchPages(ctx, markdownMetadataBaseUrl || url.origin),
sitemap: config.sitemap,
okf: config.agent?.okf,
},
);
if (operationMarkdownResponse) return operationMarkdownResponse;

const markdownRequest = resolveDocsMarkdownRequest(entry, url, event.request, {
apiRoute: discoveryApiRoute,
});
Expand Down Expand Up @@ -2392,6 +2412,7 @@ export function createDocsServer(config: Record<string, any>): DocsServer {
pathname === apiPath ||
pathname.startsWith(`${apiPath}/`) ||
pathname === apiReferencePath ||
pathname.startsWith(`${apiReferencePath}/`) ||
publicDocsPaths.has(pathname) ||
pathname.startsWith("/.well-known/agent-skills/");

Expand Down
Loading
Loading