Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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