Skip to content
Open
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ export default defineDocs({
apiReference: {
enabled: true,
path: "api-reference",
renderer: "fumadocs",
routeRoot: "api",
exclude: ["/api/internal/health", "internal/debug"],
},
Expand All @@ -264,14 +265,17 @@ export default defineDocs({
apiReference: {
enabled: true,
path: "api-reference",
renderer: "fumadocs",
specUrl: "https://petstore3.swagger.io/api/v3/openapi.json",
},
theme: fumadocs(),
});
```

- `renderer` chooses the UI: `"fumadocs"` or `"scalar"`
- defaults are framework-aware: **Next.js** uses `"fumadocs"` by default, while **TanStack Start**, **SvelteKit**, **Astro**, and **Nuxt** default to `"scalar"`
- `path` controls the public URL for the generated reference
- `specUrl` points to a hosted OpenAPI JSON document; when set, local route scanning is skipped
- `specUrl` points to a hosted OpenAPI JSON document; in Next.js it can be absolute or request-relative like `/api/openapi.json`
- `routeRoot` controls the filesystem route root to scan
- `exclude` accepts either URL-style paths (`"/api/hello"`) or route-root-relative entries (`"hello"` / `"hello/route.ts"`)
- when `specUrl` is set, `routeRoot` and `exclude` are ignored
Expand Down
12 changes: 12 additions & 0 deletions examples/next/app/api-reference/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Auto-generated by @farming-labs/next — do not edit manually.

import "@farming-labs/next/api-reference.css";
import docsConfig from "@/docs.config";
import { createNextApiReferencePage } from "@farming-labs/next/api-reference";

export const dynamic = "force-dynamic";
export const revalidate = 0;

const ApiReferencePage = createNextApiReferencePage(docsConfig);

export default ApiReferencePage;
8 changes: 8 additions & 0 deletions examples/next/app/api-reference/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Auto-generated by @farming-labs/next — do not edit manually.

import docsConfig from "@/docs.config";
import { createNextApiReferenceLayout } from "@farming-labs/next/api-reference";

const ApiReferenceLayout = createNextApiReferenceLayout(docsConfig);

export default ApiReferenceLayout;
89 changes: 89 additions & 0 deletions examples/next/app/api/openapi-spec/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const exampleOpenApiSpec = {
openapi: "3.0.4",
info: {
title: "Example Store API",
version: "1.0.0",
description:
"Hosted OpenAPI JSON served from the same Next.js app so the API reference example works in local dev and production.",
},
servers: [{ url: "https://api.example.dev" }],
tags: [
{
name: "Orders",
description: "Checkout and order management endpoints.",
},
{
name: "Users",
description: "User profile endpoints.",
},
],
paths: {
"/orders": {
get: {
tags: ["Orders"],
summary: "List orders",
description: "Returns the most recent orders for the current workspace.",
responses: {
"200": {
description: "Orders loaded successfully.",
},
},
},
},
"/orders/{orderId}": {
get: {
tags: ["Orders"],
summary: "Get order",
description: "Loads a single order by id.",
parameters: [
{
name: "orderId",
in: "path",
required: true,
schema: {
type: "string",
},
},
],
responses: {
"200": {
description: "Order found.",
},
"404": {
description: "Order not found.",
},
},
},
},
"/users/{id}": {
get: {
tags: ["Users"],
summary: "Get user profile",
description: "Returns a single user profile and permissions summary.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: {
type: "string",
},
},
],
responses: {
"200": {
description: "User loaded successfully.",
},
},
},
},
},
} as const;

export async function GET() {
return Response.json(exampleOpenApiSpec, {
headers: {
"Cache-Control": "public, max-age=60",
},
});
}
45 changes: 45 additions & 0 deletions examples/next/app/docs/integrations/openapi-spec/page.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: Hosted OpenAPI Spec
description: Render a hosted OpenAPI JSON document in Next.js with the Fumadocs API reference UI.
icon: code
---

# Hosted OpenAPI Spec

This example app enables the built-in API reference route with a hosted OpenAPI JSON document and the Fumadocs renderer.

## docs.config.tsx

```tsx
import { defineDocs } from "@farming-labs/docs";
import { colorful } from "@farming-labs/theme/colorful";

export default defineDocs({
entry: "docs",
apiReference: {
enabled: true,
path: "api-reference",
renderer: "fumadocs",
specUrl: "/api/openapi-spec",
},
theme: colorful(),
});
```

## Route behavior

In Next.js, `withDocs()` generates the `/api-reference` page automatically, so you do not need to add a manual route file for this setup.

This example serves the JSON from `/api/openapi-spec`, then points `specUrl` at that same-origin route.

Open the live example route here:

- [API Reference](/api-reference)

## When to use this

Use `specUrl` when your backend is deployed separately and already exposes an `openapi.json` file.

In Next.js you can also use a request-relative path like `/api/openapi-spec` when the same app already serves the OpenAPI JSON.

Use local route scanning instead when your API handlers live inside the same Next.js app.
6 changes: 6 additions & 0 deletions examples/next/docs.config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import { colorful } from "@farming-labs/theme/colorful";

export default defineDocs({
entry: "docs",
apiReference: {
enabled: true,
path: "api-reference",
renderer: "fumadocs",
specUrl: "/api/openapi-spec",
},
github: {
url: "https://github.com/farming-labs/docs",
branch: "main",
Expand Down
2 changes: 1 addition & 1 deletion examples/next/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next-build/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
67 changes: 67 additions & 0 deletions packages/docs/src/api-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
buildApiReferenceScalarCss,
buildApiReferenceOpenApiDocument,
buildApiReferenceOpenApiDocumentAsync,
resolveApiReferenceRenderer,
} from "./api-reference.js";
import { defineDocs } from "./define-docs.js";

Expand Down Expand Up @@ -127,6 +128,35 @@ describe("buildApiReferenceOpenApiDocument", () => {
}
});

it("resolves a request-relative OpenAPI JSON when a base URL is provided", async () => {
const fetchSpy = vi.fn().mockImplementation(async () => createRemoteOpenApiResponse());
vi.stubGlobal("fetch", fetchSpy);

const config = defineDocs({
entry: "docs",
apiReference: {
enabled: true,
specUrl: "/api/openapi.json",
},
});

const document = await buildApiReferenceOpenApiDocumentAsync(config, {
framework: "next",
baseUrl: "https://docs.example.com",
});

expect(fetchSpy).toHaveBeenCalledTimes(1);
expect((fetchSpy.mock.calls[0]?.[0] as URL).href).toBe(
"https://docs.example.com/api/openapi.json",
);
expect(document).toMatchObject({
openapi: "3.0.4",
info: {
title: "Remote Pets",
},
});
});

it("renders hosted OpenAPI HTML for every framework", async () => {
vi.stubGlobal(
"fetch",
Expand Down Expand Up @@ -211,4 +241,41 @@ describe("buildApiReferenceOpenApiDocument", () => {
expect(css).toContain("--scalar-button-1-color: #0b0b0b;");
expect(css).toContain("var(--scalar-theme-foreground) 10%");
});

it("defaults the API reference renderer to fumadocs for Next.js", () => {
const config = defineDocs({
entry: "docs",
apiReference: {
enabled: true,
},
});

expect(resolveApiReferenceRenderer(config.apiReference, "next")).toBe("fumadocs");
});

it("defaults the API reference renderer to scalar outside Next.js", () => {
const config = defineDocs({
entry: "docs",
apiReference: {
enabled: true,
},
});

expect(resolveApiReferenceRenderer(config.apiReference, "tanstack-start")).toBe("scalar");
expect(resolveApiReferenceRenderer(config.apiReference, "sveltekit")).toBe("scalar");
expect(resolveApiReferenceRenderer(config.apiReference, "astro")).toBe("scalar");
expect(resolveApiReferenceRenderer(config.apiReference, "nuxt")).toBe("scalar");
});

it("respects an explicit API reference renderer selection", () => {
const config = defineDocs({
entry: "docs",
apiReference: {
enabled: true,
renderer: "scalar",
},
});

expect(resolveApiReferenceRenderer(config.apiReference, "next")).toBe("scalar");
});
});
38 changes: 33 additions & 5 deletions packages/docs/src/api-reference.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { getHtmlDocument } from "@scalar/core/libs/html-rendering";
import type { DocsConfig, DocsTheme } from "./types.js";
import type { ApiReferenceRenderer, DocsConfig, DocsTheme } from "./types.js";

export type { ApiReferenceRenderer };

type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";

Expand All @@ -22,13 +24,15 @@ export interface ResolvedApiReferenceConfig {
enabled: boolean;
path: string;
specUrl?: string;
renderer?: ApiReferenceRenderer;
routeRoot: string;
exclude: string[];
}

interface BuildApiReferenceOptions {
framework: ApiReferenceFramework;
rootDir?: string;
baseUrl?: string;
}

interface BuildApiReferenceHtmlOptions extends BuildApiReferenceOptions {
Expand Down Expand Up @@ -56,6 +60,7 @@ export function resolveApiReferenceConfig(
enabled: true,
path: "api-reference",
specUrl: undefined,
renderer: undefined,
routeRoot: "api",
exclude: [],
};
Expand All @@ -66,6 +71,7 @@ export function resolveApiReferenceConfig(
enabled: false,
path: "api-reference",
specUrl: undefined,
renderer: undefined,
routeRoot: "api",
exclude: [],
};
Expand All @@ -75,11 +81,26 @@ export function resolveApiReferenceConfig(
enabled: value.enabled !== false,
path: normalizePathSegment(value.path ?? "api-reference"),
specUrl: normalizeRemoteSpecUrl(value.specUrl),
renderer: normalizeApiReferenceRenderer(value.renderer),
routeRoot: normalizePathSegment(value.routeRoot ?? "api") || "api",
exclude: normalizeApiReferenceExcludes(value.exclude),
};
}

function normalizeApiReferenceRenderer(value?: string): ApiReferenceRenderer | undefined {
if (value === "fumadocs" || value === "scalar") return value;
return undefined;
}

export function resolveApiReferenceRenderer(
value: DocsConfig["apiReference"],
framework: ApiReferenceFramework,
): ApiReferenceRenderer {
const config = resolveApiReferenceConfig(value);
if (config.renderer) return config.renderer;
return framework === "next" ? "fumadocs" : "scalar";
}

function normalizeRemoteSpecUrl(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
Expand Down Expand Up @@ -524,7 +545,7 @@ export async function buildApiReferenceOpenApiDocumentAsync(
}

try {
const document = await fetchRemoteOpenApiDocument(apiReference.specUrl);
const document = await fetchRemoteOpenApiDocument(apiReference.specUrl, options.baseUrl);
return normalizeRemoteOpenApiDocument(document, config);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
Expand Down Expand Up @@ -608,12 +629,19 @@ function buildApiReferenceHtmlDocumentFromDocument(
});
}

async function fetchRemoteOpenApiDocument(specUrl: string): Promise<Record<string, unknown>> {
async function fetchRemoteOpenApiDocument(
specUrl: string,
baseUrl?: string,
): Promise<Record<string, unknown>> {
let url: URL;
try {
url = new URL(specUrl);
url = baseUrl ? new URL(specUrl, baseUrl) : new URL(specUrl);
} catch {
throw new Error("`apiReference.specUrl` must be an absolute URL.");
throw new Error(
baseUrl
? "`apiReference.specUrl` must be an absolute URL or a request-relative path."
: "`apiReference.specUrl` must be an absolute URL.",
);
}

const response = await fetch(url, {
Expand Down
Loading
Loading