diff --git a/README.md b/README.md index 42db5f02..3e2c0e04 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ export default defineDocs({ apiReference: { enabled: true, path: "api-reference", + renderer: "fumadocs", routeRoot: "api", exclude: ["/api/internal/health", "internal/debug"], }, @@ -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 diff --git a/examples/next/app/api-reference/[[...slug]]/page.tsx b/examples/next/app/api-reference/[[...slug]]/page.tsx new file mode 100644 index 00000000..bb0890e0 --- /dev/null +++ b/examples/next/app/api-reference/[[...slug]]/page.tsx @@ -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; diff --git a/examples/next/app/api-reference/layout.tsx b/examples/next/app/api-reference/layout.tsx new file mode 100644 index 00000000..8437a06e --- /dev/null +++ b/examples/next/app/api-reference/layout.tsx @@ -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; diff --git a/examples/next/app/api/openapi-spec/route.ts b/examples/next/app/api/openapi-spec/route.ts new file mode 100644 index 00000000..bd79759f --- /dev/null +++ b/examples/next/app/api/openapi-spec/route.ts @@ -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", + }, + }); +} diff --git a/examples/next/app/docs/integrations/openapi-spec/page.mdx b/examples/next/app/docs/integrations/openapi-spec/page.mdx new file mode 100644 index 00000000..9457840d --- /dev/null +++ b/examples/next/app/docs/integrations/openapi-spec/page.mdx @@ -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. diff --git a/examples/next/docs.config.tsx b/examples/next/docs.config.tsx index 4dc21662..4eeccdb6 100644 --- a/examples/next/docs.config.tsx +++ b/examples/next/docs.config.tsx @@ -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", diff --git a/examples/next/next-env.d.ts b/examples/next/next-env.d.ts index c4b7818f..71fe015d 100644 --- a/examples/next/next-env.d.ts +++ b/examples/next/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/packages/docs/src/api-reference.test.ts b/packages/docs/src/api-reference.test.ts index 32c942e7..ded6fcc1 100644 --- a/packages/docs/src/api-reference.test.ts +++ b/packages/docs/src/api-reference.test.ts @@ -7,6 +7,7 @@ import { buildApiReferenceScalarCss, buildApiReferenceOpenApiDocument, buildApiReferenceOpenApiDocumentAsync, + resolveApiReferenceRenderer, } from "./api-reference.js"; import { defineDocs } from "./define-docs.js"; @@ -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", @@ -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"); + }); }); diff --git a/packages/docs/src/api-reference.ts b/packages/docs/src/api-reference.ts index 6d2588bf..ce7e79f4 100644 --- a/packages/docs/src/api-reference.ts +++ b/packages/docs/src/api-reference.ts @@ -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"; @@ -22,6 +24,7 @@ export interface ResolvedApiReferenceConfig { enabled: boolean; path: string; specUrl?: string; + renderer?: ApiReferenceRenderer; routeRoot: string; exclude: string[]; } @@ -29,6 +32,7 @@ export interface ResolvedApiReferenceConfig { interface BuildApiReferenceOptions { framework: ApiReferenceFramework; rootDir?: string; + baseUrl?: string; } interface BuildApiReferenceHtmlOptions extends BuildApiReferenceOptions { @@ -56,6 +60,7 @@ export function resolveApiReferenceConfig( enabled: true, path: "api-reference", specUrl: undefined, + renderer: undefined, routeRoot: "api", exclude: [], }; @@ -66,6 +71,7 @@ export function resolveApiReferenceConfig( enabled: false, path: "api-reference", specUrl: undefined, + renderer: undefined, routeRoot: "api", exclude: [], }; @@ -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; @@ -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"; @@ -608,12 +629,19 @@ function buildApiReferenceHtmlDocumentFromDocument( }); } -async function fetchRemoteOpenApiDocument(specUrl: string): Promise> { +async function fetchRemoteOpenApiDocument( + specUrl: string, + baseUrl?: string, +): Promise> { 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, { diff --git a/packages/docs/src/cli/init.scaffold.test.ts b/packages/docs/src/cli/init.scaffold.test.ts index 98c22cd2..9c5b5b9c 100644 --- a/packages/docs/src/cli/init.scaffold.test.ts +++ b/packages/docs/src/cli/init.scaffold.test.ts @@ -154,16 +154,16 @@ describe("scaffoldNextJs (app dir consistency)", () => { written, ); - expect(written).toContain("app/api-reference/[[...slug]]/route.ts"); + expect(written).toContain("app/api-reference/[[...slug]]/page.tsx"); const config = fs.readFileSync(path.join(tmpDir, "docs.config.ts"), "utf-8"); expect(config).toContain("apiReference:"); const route = fs.readFileSync( - path.join(tmpDir, "app/api-reference/[[...slug]]/route.ts"), + path.join(tmpDir, "app/api-reference/[[...slug]]/page.tsx"), "utf-8", ); - expect(route).toContain("createNextApiReference"); + expect(route).toContain("createNextApiReferencePage"); }); }); diff --git a/packages/docs/src/cli/init.ts b/packages/docs/src/cli/init.ts index be7b077c..d6edf4db 100644 --- a/packages/docs/src/cli/init.ts +++ b/packages/docs/src/cli/init.ts @@ -42,7 +42,7 @@ import { customThemeTsTemplate, customThemeCssTemplate, docsLayoutTemplate, - nextApiReferenceRouteTemplate, + nextApiReferencePageTemplate, nextLocaleDocPageTemplate, nextLocalizedPageTemplate, postcssConfigTemplate, @@ -1251,8 +1251,8 @@ function scaffoldNextJs( write(`${appDir}/${cfg.entry}/layout.tsx`, docsLayoutTemplate(cfg)); if (cfg.apiReference) { - const apiReferenceRoute = `${appDir}/${cfg.apiReference.path}/[[...slug]]/route.ts`; - write(apiReferenceRoute, nextApiReferenceRouteTemplate(cfg, apiReferenceRoute)); + const apiReferencePage = `${appDir}/${cfg.apiReference.path}/[[...slug]]/page.tsx`; + write(apiReferencePage, nextApiReferencePageTemplate(cfg, apiReferencePage)); } write("postcss.config.mjs", postcssConfigTemplate()); diff --git a/packages/docs/src/cli/templates.test.ts b/packages/docs/src/cli/templates.test.ts index 415caaef..40e53145 100644 --- a/packages/docs/src/cli/templates.test.ts +++ b/packages/docs/src/cli/templates.test.ts @@ -4,6 +4,7 @@ import { docsConfigTemplate, docsLayoutTemplate, nextApiReferenceRouteTemplate, + nextApiReferencePageTemplate, rootLayoutTemplate, injectRootProviderIntoLayout, tanstackDocsConfigTemplate, @@ -84,6 +85,17 @@ describe("docsLayoutTemplate", () => { expect(out).toContain("createNextApiReference"); expect(out).toContain('import docsConfig from "../../../docs.config"'); }); + + it("creates a Next.js API reference page for the fumadocs renderer", () => { + const out = nextApiReferencePageTemplate( + { ...baseConfig, useAlias: false, nextAppDir: "app" }, + "app/api-reference/[[...slug]]/page.tsx", + ); + expect(out).toContain('import "@farming-labs/next/api-reference.css"'); + expect(out).toContain("createNextApiReferencePage"); + expect(out).toContain('import docsConfig from "../../../docs.config"'); + expect(out).toContain('export const dynamic = "force-dynamic"'); + }); }); describe("rootLayoutTemplate", () => { diff --git a/packages/docs/src/cli/templates.ts b/packages/docs/src/cli/templates.ts index 283fa702..cd0d3016 100644 --- a/packages/docs/src/cli/templates.ts +++ b/packages/docs/src/cli/templates.ts @@ -606,6 +606,24 @@ export const revalidate = false; `; } +export function nextApiReferencePageTemplate(cfg: TemplateConfig, filePath: string): string { + const appDir = cfg.nextAppDir ?? "app"; + const configImport = nextApiReferenceConfigImport(cfg.useAlias, appDir, filePath); + + return ` +import "@farming-labs/next/api-reference.css"; +import docsConfig from "${configImport}"; +import { createNextApiReferencePage } from "@farming-labs/next/api-reference"; + +const ApiReferencePage = createNextApiReferencePage(docsConfig); + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export default ApiReferencePage; +`; +} + export function nextLocaleDocPageTemplate(defaultLocale: string): string { return `\ import type { ComponentType } from "react"; diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts index 561cd43d..20fdbcc0 100644 --- a/packages/docs/src/index.ts +++ b/packages/docs/src/index.ts @@ -15,6 +15,7 @@ export { createTheme, extendTheme } from "./create-theme.js"; export { resolveDocsI18n, resolveDocsLocale, resolveDocsPath } from "./i18n.js"; export { resolveTitle, resolveOGImage, buildPageOpenGraph, buildPageTwitter } from "./metadata.js"; export type { + ApiReferenceRenderer, DocsConfig, ApiReferenceConfig, DocsI18nConfig, diff --git a/packages/docs/src/server.ts b/packages/docs/src/server.ts index f0a7c6eb..64c8b7c4 100644 --- a/packages/docs/src/server.ts +++ b/packages/docs/src/server.ts @@ -1,5 +1,6 @@ export { resolveApiReferenceConfig, + resolveApiReferenceRenderer, buildApiReferenceOpenApiDocument, buildApiReferenceOpenApiDocumentAsync, buildApiReferenceHtmlDocument, @@ -9,6 +10,7 @@ export { } from "./api-reference.js"; export type { ApiReferenceFramework, + ApiReferenceRenderer, ApiReferenceRoute, ResolvedApiReferenceConfig, } from "./api-reference.js"; diff --git a/packages/docs/src/types.ts b/packages/docs/src/types.ts index 0daa7261..6030bc8a 100644 --- a/packages/docs/src/types.ts +++ b/packages/docs/src/types.ts @@ -1134,6 +1134,8 @@ export interface DocsI18nConfig { defaultLocale?: string; } +export type ApiReferenceRenderer = "fumadocs" | "scalar"; + export interface ApiReferenceConfig { /** * Whether to enable generated API reference pages. @@ -1148,11 +1150,15 @@ export interface ApiReferenceConfig { */ path?: string; /** - * Absolute URL to a remote OpenAPI JSON document. + * URL to a remote OpenAPI JSON document. * * When provided, the API reference is generated from this hosted spec instead * of scanning local framework route files. * + * Supports: + * - absolute URLs like `https://example.com/openapi.json` + * - request-relative URLs like `/api/openapi.json` in Next.js + * * @example * ```ts * apiReference: { @@ -1162,6 +1168,17 @@ export interface ApiReferenceConfig { * ``` */ specUrl?: string; + /** + * Which renderer to use for the API reference UI. + * + * - `"fumadocs"` uses `fumadocs-openapi` + * - `"scalar"` uses the existing Scalar renderer + * + * Defaults are framework-aware: + * - Next.js: `"fumadocs"` + * - TanStack Start / SvelteKit / Astro / Nuxt: `"scalar"` + */ + renderer?: ApiReferenceRenderer; /** * Filesystem route root to scan for API handlers. * diff --git a/packages/fumadocs/src/docs-layout.tsx b/packages/fumadocs/src/docs-layout.tsx index 3b331dae..ae40b761 100644 --- a/packages/fumadocs/src/docs-layout.tsx +++ b/packages/fumadocs/src/docs-layout.tsx @@ -1,4 +1,4 @@ -import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { DocsLayout as FumadocsDocsLayout } from "fumadocs-ui/layouts/docs"; import { Suspense, type ReactNode } from "react"; import { serializeIcon } from "./serialize-icon.js"; import { buildPageOpenGraph, buildPageTwitter } from "@farming-labs/docs"; @@ -21,13 +21,13 @@ import { withLangInUrl } from "./i18n.js"; // ─── Tree node types (mirrors fumadocs-core/page-tree) ─────────────── interface PageNode { type: "page"; - name: string; + name: ReactNode; url: string; icon?: ReactNode; } interface FolderNode { type: "folder"; - name: string; + name: ReactNode; icon?: ReactNode; index?: PageNode; children: (PageNode | FolderNode)[]; @@ -37,7 +37,7 @@ interface FolderNode { type TreeNode = PageNode | FolderNode; interface TreeRoot { - name: string; + name: ReactNode; children: TreeNode[]; } @@ -629,7 +629,11 @@ function LayoutStyle({ layout }: { layout?: LayoutDimensions }) { // ─── createDocsLayout ──────────────────────────────────────────────── -export function createDocsLayout(config: DocsConfig, options?: { locale?: string }) { +function createConfiguredLayout( + config: DocsConfig, + LayoutComponent: typeof FumadocsDocsLayout, + options?: { locale?: string; tree?: TreeRoot; pageClient?: boolean }, +) { const tocConfig = config.theme?.ui?.layout?.toc; const tocEnabled = tocConfig?.enabled !== false; const tocStyle = (tocConfig as any)?.style as "default" | "directional" | undefined; @@ -768,7 +772,7 @@ export function createDocsLayout(config: DocsConfig, options?: { locale?: string const descriptionMap = buildDescriptionMap(localeContext); return function DocsLayoutWrapper({ children }: { children: ReactNode }) { - const tree = buildTree(config, localeContext, !!sidebarFlat); + const tree = options?.tree ?? buildTree(config, localeContext, !!sidebarFlat); const localizedTree = i18n ? localizeTreeUrls(tree, activeLocale) : tree; const finalSidebarProps = { ...sidebarProps } as Record; @@ -801,7 +805,7 @@ export function createDocsLayout(config: DocsConfig, options?: { locale?: string return (
- )} - - - {children} - - - + {options?.pageClient === false ? ( + children + ) : ( + + + {children} + + + )} +
); }; } +export function createDocsLayout( + config: DocsConfig, + options?: { locale?: string; tree?: TreeRoot; pageClient?: boolean }, +) { + return createConfiguredLayout(config, FumadocsDocsLayout, { + ...options, + pageClient: options?.pageClient ?? true, + }); +} + /** Resolve `boolean | { enabled?: boolean }` to a simple boolean. */ function resolveBool(v: boolean | { enabled?: boolean } | undefined): boolean { if (v === undefined) return false; diff --git a/packages/next/package.json b/packages/next/package.json index 3f4f5d83..e059dd3d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -11,7 +11,8 @@ "license": "MIT", "author": "Farming Labs", "files": [ - "dist" + "dist", + "styles" ], "type": "module", "main": "./dist/index.mjs", @@ -27,6 +28,7 @@ "import": "./dist/api-reference.mjs", "default": "./dist/api-reference.mjs" }, + "./api-reference.css": "./styles/api-reference.css", "./client-callbacks": { "types": "./dist/client-callbacks.d.mts", "import": "./dist/client-callbacks.mjs", @@ -75,7 +77,9 @@ "@mdx-js/react": "^3.1.0", "@next/mdx": "^16.1.6", "@scalar/nextjs-api-reference": "0.10.3", + "fumadocs-openapi": "10.3.6", "fumadocs-core": "^16.6.1", + "fumadocs-ui": "^16.6.1", "remark-frontmatter": "^5.0.0", "remark-gfm": "^4.0.1", "remark-mdx-frontmatter": "^5.0.0" @@ -85,6 +89,7 @@ "@farming-labs/theme": "workspace:*", "@types/node": "^22.10.0", "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "tsdown": "^0.20.3", "typescript": "^5.9.3", "vitest": "^3.2.4" diff --git a/packages/next/src/api-reference.test.ts b/packages/next/src/api-reference.test.ts index 52cb2c09..1689867a 100644 --- a/packages/next/src/api-reference.test.ts +++ b/packages/next/src/api-reference.test.ts @@ -1,13 +1,122 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { renderToStaticMarkup } from "react-dom/server"; +import { jsx } from "react/jsx-runtime"; import { buildNextOpenApiDocument, createNextApiReference, + createNextApiReferenceLayout, + createNextApiReferencePage, + getNextApiReferenceMode, withNextApiReferenceBanner, } from "./api-reference.js"; +const nextHeadersMock = vi.hoisted(() => ({ + headers: vi.fn(), +})); + +const createAPIPageMock = vi.hoisted(() => vi.fn()); +const loaderMock = vi.hoisted(() => vi.fn()); +const notebookLayoutMock = vi.hoisted(() => vi.fn()); +const notebookPageMock = vi.hoisted(() => vi.fn()); +const notebookTitleMock = vi.hoisted(() => vi.fn()); +const notebookDescriptionMock = vi.hoisted(() => vi.fn()); +const notebookBodyMock = vi.hoisted(() => vi.fn()); +const redirectMock = vi.hoisted(() => + vi.fn((url: string) => { + throw new Error(`redirect:${url}`); + }), +); +const notFoundMock = vi.hoisted(() => + vi.fn(() => { + throw new Error("notFound"); + }), +); + +vi.mock("next/headers", () => nextHeadersMock); +vi.mock("next/navigation", () => ({ + redirect: redirectMock, + notFound: notFoundMock, +})); + +vi.mock("fumadocs-ui/layouts/notebook", () => ({ + DocsLayout: notebookLayoutMock.mockImplementation(function MockDocsLayout( + props: Record, + ) { + return jsx("div", { + "data-notebook-layout": true, + "data-nav-title": + typeof (props.nav as Record | undefined)?.title === "string" + ? (props.nav as Record).title + : "Docs", + children: props.children, + }); + }), +})); + +vi.mock("fumadocs-ui/layouts/notebook/page", () => ({ + DocsPage: notebookPageMock.mockImplementation(function MockDocsPage( + props: Record, + ) { + return jsx("div", { + "data-notebook-page": true, + "data-toc": Array.isArray(props.toc) ? String(props.toc.length) : "0", + children: props.children, + }); + }), + DocsTitle: notebookTitleMock.mockImplementation(function MockDocsTitle( + props: Record, + ) { + return jsx("h1", { "data-notebook-title": true, children: props.children }); + }), + DocsDescription: notebookDescriptionMock.mockImplementation(function MockDocsDescription( + props: Record, + ) { + return jsx("p", { "data-notebook-description": true, children: props.children }); + }), + DocsBody: notebookBodyMock.mockImplementation(function MockDocsBody( + props: Record, + ) { + return jsx("div", { "data-notebook-body": true, children: props.children }); + }), +})); + +vi.mock("./client-callbacks.js", () => ({ + default: function MockDocsClientCallbacks() { + return null; + }, +})); + +vi.mock("fumadocs-openapi/server", () => ({ + createOpenAPI: vi.fn(() => ({ + options: {}, + getSchemas: vi.fn(), + getSchema: vi.fn(), + createProxy: vi.fn(), + })), + openapiPlugin: vi.fn(() => ({ name: "fumadocs:openapi" })), + openapiSource: vi.fn(async () => ({ files: [] })), +})); + +vi.mock("fumadocs-openapi/ui", () => ({ + createAPIPage: createAPIPageMock.mockImplementation( + () => + function MockApiPage(props: Record) { + return jsx("div", { + "data-api-page": true, + "data-document": String(props.document ?? ""), + children: "mock-api-page", + }); + }, + ), +})); + +vi.mock("fumadocs-core/source", () => ({ + loader: loaderMock, +})); + describe("buildNextOpenApiDocument", () => { let tmpDir: string; let originalCwd: string; @@ -19,6 +128,17 @@ describe("buildNextOpenApiDocument", () => { afterEach(() => { process.chdir(originalCwd); + vi.unstubAllGlobals(); + nextHeadersMock.headers.mockReset(); + createAPIPageMock.mockClear(); + loaderMock.mockReset(); + notebookLayoutMock.mockClear(); + notebookPageMock.mockClear(); + notebookTitleMock.mockClear(); + notebookDescriptionMock.mockClear(); + notebookBodyMock.mockClear(); + redirectMock.mockClear(); + notFoundMock.mockClear(); rmSync(tmpDir, { recursive: true, force: true }); }); @@ -145,6 +265,45 @@ describe("withNextApiReferenceBanner", () => { }); describe("createNextApiReference", () => { + beforeEach(() => { + loaderMock.mockImplementation(() => { + const page = { + url: "/api-reference/pets/get", + slugs: ["pets", "get"], + data: { + title: "List pets", + description: "Returns pets.", + getAPIPageProps: () => ({ + document: "main", + operations: [], + webhooks: [], + }), + }, + }; + + return { + getPages: () => [page], + getPage: (slugs: string[]) => (slugs.join("/") === "pets/get" ? page : undefined), + getPageTree: () => ({ + name: "Docs", + children: [ + { + type: "folder", + name: "Pets", + children: [ + { + type: "page", + name: "List pets", + url: "/api-reference/pets/get", + }, + ], + }, + ], + }), + }; + }); + }); + it("returns 404 when apiReference is disabled", async () => { const handler = createNextApiReference({ entry: "docs", @@ -155,4 +314,406 @@ describe("createNextApiReference", () => { expect(response.status).toBe(404); }); + + it("defaults to the fumadocs renderer for Next.js API references", () => { + expect( + getNextApiReferenceMode({ + entry: "docs", + apiReference: { + enabled: true, + }, + }), + ).toBe("fumadocs"); + }); + + it("respects an explicit scalar renderer for Next.js API references", () => { + expect( + getNextApiReferenceMode({ + entry: "docs", + apiReference: { + enabled: true, + renderer: "scalar", + }, + }), + ).toBe("scalar"); + }); + + it("creates a fumadocs API reference page component", async () => { + nextHeadersMock.headers.mockResolvedValue( + new Headers({ + host: "docs.example.com", + "x-forwarded-proto": "https", + }), + ); + + const Page = createNextApiReferencePage({ + entry: "docs", + metadata: { + description: "Generated API docs", + }, + apiReference: { + enabled: true, + specUrl: "/api/openapi.json", + }, + }); + + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + paths: { + "/pets": { + get: { + summary: "List pets", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); + + vi.stubGlobal("fetch", fetchSpy); + + const element = await Page({ + params: Promise.resolve({ + slug: ["pets", "get"], + }), + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect((fetchSpy.mock.calls[0]?.[0] as URL).href).toBe( + "https://docs.example.com/api/openapi.json", + ); + expect(createAPIPageMock).toHaveBeenCalledTimes(1); + expect(element).toBeTruthy(); + expect(renderToStaticMarkup(element)).toContain("List pets"); + }); + + it("renders the published notebook page components for fumadocs mode", async () => { + nextHeadersMock.headers.mockResolvedValue( + new Headers({ + host: "docs.example.com", + "x-forwarded-proto": "https", + }), + ); + + const Page = createNextApiReferencePage({ + entry: "docs", + nav: { + title: "Docs", + url: "/docs", + }, + metadata: { + description: "Generated API docs", + }, + apiReference: { + enabled: true, + specUrl: "/api/openapi.json", + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + tags: [ + { + name: "Pets", + }, + ], + paths: { + "/pets": { + get: { + tags: ["Pets"], + summary: "List pets", + description: "Returns pets.", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ), + ); + + await Page({ + params: Promise.resolve({ + slug: ["pets", "get"], + }), + }); + + expect(notebookPageMock).toHaveBeenCalledTimes(1); + expect(notebookTitleMock).toHaveBeenCalledTimes(1); + expect(notebookDescriptionMock).toHaveBeenCalledTimes(1); + expect(notebookBodyMock).toHaveBeenCalledTimes(1); + }); + + it("redirects the base api-reference page to the first generated OpenAPI page", async () => { + nextHeadersMock.headers.mockResolvedValue( + new Headers({ + host: "docs.example.com", + "x-forwarded-proto": "https", + }), + ); + + const Page = createNextApiReferencePage({ + entry: "docs", + apiReference: { + enabled: true, + specUrl: "/api/openapi.json", + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + paths: { + "/pets": { + get: { + summary: "List pets", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ), + ); + + await expect(Page()).rejects.toThrow("redirect:/api-reference/pets/get"); + expect(redirectMock).toHaveBeenCalledWith("/api-reference/pets/get"); + }); + + it("returns notFound() for an unknown generated OpenAPI page slug", async () => { + nextHeadersMock.headers.mockResolvedValue( + new Headers({ + host: "docs.example.com", + "x-forwarded-proto": "https", + }), + ); + + const Page = createNextApiReferencePage({ + entry: "docs", + apiReference: { + enabled: true, + specUrl: "/api/openapi.json", + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + paths: { + "/pets": { + get: { + summary: "List pets", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ), + ); + + await expect( + Page({ + params: Promise.resolve({ + slug: ["missing"], + }), + }), + ).rejects.toThrow("notFound"); + expect(notFoundMock).toHaveBeenCalledTimes(1); + }); + + it("creates a notebook layout for the API reference sidebar", async () => { + nextHeadersMock.headers.mockResolvedValue( + new Headers({ + host: "docs.example.com", + "x-forwarded-proto": "https", + }), + ); + + const Layout = createNextApiReferenceLayout({ + entry: "docs", + nav: { + title: "Docs", + url: "/docs", + }, + apiReference: { + enabled: true, + specUrl: "/api/openapi.json", + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + tags: [{ name: "Pets" }], + paths: { + "/pets": { + get: { + tags: ["Pets"], + summary: "List pets", + responses: { + "200": { + description: "OK", + }, + }, + }, + }, + }, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ), + ); + + const element = await Layout({ + children: jsx("div", { children: "child" }), + }); + const html = renderToStaticMarkup(element); + + expect(html).toContain("child"); + expect(notebookLayoutMock).toHaveBeenCalledTimes(1); + const sidebar = notebookLayoutMock.mock.calls[0]?.[0]?.sidebar as + | Record + | undefined; + expect(sidebar?.banner).toBeTruthy(); + const bannerHtml = renderToStaticMarkup(sidebar?.banner as any); + expect(bannerHtml).toContain("Documentation"); + expect(bannerHtml).toContain("API Reference"); + expect(notebookLayoutMock.mock.calls[0]?.[0]?.tree).toMatchObject({ + children: [ + { + type: "folder", + name: "Pets", + children: [ + { + type: "page", + name: "List pets", + url: "/api-reference/pets/get", + }, + ], + }, + ], + }); + }); + + it("resolves request-relative specs in the scalar route handler", async () => { + const handler = createNextApiReference({ + entry: "docs", + apiReference: { + enabled: true, + renderer: "scalar", + specUrl: "/api/openapi.json", + }, + }); + + const fetchSpy = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + openapi: "3.0.4", + info: { + title: "Remote Pets", + version: "1.2.3", + }, + paths: {}, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + }, + ), + ); + + vi.stubGlobal("fetch", fetchSpy); + + const response = await handler(new Request("https://docs.example.com/api-reference")); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect((fetchSpy.mock.calls[0]?.[0] as URL).href).toBe( + "https://docs.example.com/api/openapi.json", + ); + expect(response.status).toBe(200); + }); }); diff --git a/packages/next/src/api-reference.tsx b/packages/next/src/api-reference.tsx index f916fb62..a88eb4a2 100644 --- a/packages/next/src/api-reference.tsx +++ b/packages/next/src/api-reference.tsx @@ -1,14 +1,17 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; -import type { ReactNode } from "react"; import { ApiReference } from "@scalar/nextjs-api-reference"; import type { DocsConfig } from "@farming-labs/docs"; +import { notFound, redirect } from "next/navigation"; +import type { ReactNode } from "react"; import { buildApiReferenceOpenApiDocumentAsync, buildApiReferencePageTitle, buildApiReferenceScalarCss, resolveApiReferenceConfig, + resolveApiReferenceRenderer, } from "@farming-labs/docs/server"; +import DocsClientCallbacks from "./client-callbacks.js"; export { resolveApiReferenceConfig }; @@ -26,6 +29,22 @@ interface ApiReferenceRoute { parameters: Array>; } +export interface NextApiReferenceSourceState { + apiReference: ReturnType; + document: Record; + info: { + title: string; + description?: string; + }; + pages: Array<{ url: string } & Record>; + server: any; + source: { + getPage: (slug?: string[]) => any; + getPages: () => Array; + getPageTree: () => any; + }; +} + const ROUTE_FILE_RE = /^route\.(ts|tsx|js|jsx)$/; const METHOD_RE = /export\s+(?:async\s+function|function|const)\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g; @@ -106,6 +125,15 @@ function humanizeSegment(value: string): string { return normalized.replace(/\b\w/g, (char) => char.toUpperCase()); } +function slugifyApiReferencePageName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +} + function endpointSegmentFromFsSegment(value: string): string { if (value.startsWith("[[...") && value.endsWith("]]")) return `{${value.slice(5, -2)}}`; if (value.startsWith("[...") && value.endsWith("]")) return `{${value.slice(4, -1)}}`; @@ -192,6 +220,38 @@ function isThemeToggleHidden(config: DocsConfig): boolean { return false; } +function getOriginFromRequest(request?: Request): string | undefined { + if (!request) return undefined; + + try { + return new URL(request.url).origin; + } catch { + return undefined; + } +} + +function getForwardedHeaderValue(value: string | null): string | undefined { + if (!value) return undefined; + const first = value + .split(",") + .map((entry) => entry.trim()) + .find(Boolean); + return first || undefined; +} + +async function getOriginFromNextHeaders(): Promise { + const { headers } = await import("next/headers"); + const headerList = await headers(); + const host = + getForwardedHeaderValue(headerList.get("x-forwarded-host")) ?? + getForwardedHeaderValue(headerList.get("host")); + + if (!host) return undefined; + + const protocol = getForwardedHeaderValue(headerList.get("x-forwarded-proto")) ?? "https"; + return `${protocol}://${host}`; +} + function buildPathParameters(fsSegments: string[]): Array> { const parameters: Array> = []; @@ -337,7 +397,7 @@ export function buildNextOpenApiDocument(config: DocsConfig): Record" : "▣"; + const isApi = current === "api"; return ( + {isApi ? "" : "▣"} + + ); +} + +function ChevronStack() { + return ( + ); } @@ -419,19 +500,19 @@ function getApiReferenceSwitcherTheme(config: DocsConfig) { titleStyle: { fontFamily: isPixelBorder ? "var(--fd-font-mono, var(--font-geist-mono, ui-monospace, monospace))" - : undefined, + : "var(--fd-font-sans, var(--font-geist-sans, system-ui, sans-serif))", textTransform: isPixelBorder ? ("uppercase" as const) : undefined, letterSpacing: isPixelBorder ? "0.08em" : undefined, - fontSize: isPixelBorder ? 12 : 14, + fontSize: isPixelBorder ? 12 : 13, }, descriptionStyle: { fontFamily: isPixelBorder ? "var(--fd-font-mono, var(--font-geist-mono, ui-monospace, monospace))" - : undefined, + : "var(--fd-font-sans, var(--font-geist-sans, system-ui, sans-serif))", textTransform: isPixelBorder ? ("uppercase" as const) : undefined, letterSpacing: isPixelBorder ? "0.04em" : undefined, - fontSize: isPixelBorder ? 11 : 12, - opacity: isPixelBorder ? 0.74 : 0.62, + fontSize: isPixelBorder ? 11 : 11, + opacity: isPixelBorder ? 0.74 : 0.72, }, }; } @@ -456,17 +537,17 @@ function SwitcherOption({ href={href} style={{ display: "grid", - gridTemplateColumns: "20px 1fr 14px", - gap: 12, - alignItems: "start", + gridTemplateColumns: "20px minmax(0, 1fr)", + gap: 11, + alignItems: "center", padding: "11px 12px", - borderRadius: theme.cardRadius, + borderRadius: "0.625rem", textDecoration: "none", color: "inherit", background: current - ? "color-mix(in srgb, var(--color-fd-primary, #3a7) 10%, transparent)" + ? "linear-gradient(90deg, color-mix(in srgb, var(--color-fd-primary, #facc15) 20%, transparent), color-mix(in srgb, var(--color-fd-primary, #facc15) 14%, transparent))" : "transparent", - backgroundImage: !current ? theme.backgroundImage : undefined, + backgroundImage: current ? theme.backgroundImage : undefined, }} > {title === "API Reference" ? "" : "▣"} - - {title} + + {title} {description} - ); } @@ -545,26 +615,15 @@ function ApiReferenceSwitcher({ justifyContent: "space-between", gap: 10, cursor: "pointer", - padding: "11px 13px", + padding: "10px 13px", background: "color-mix(in srgb, var(--color-fd-card, #202020) 96%, transparent)", - borderBottom: - "1px solid color-mix(in srgb, var(--color-fd-border, #2a2a2a) 100%, transparent)", }} > {currentLabel} - +
@@ -595,6 +654,11 @@ function ApiReferenceSwitcher({ ); } +function getExistingSidebarBanner(config: DocsConfig): unknown { + if (!config.sidebar || config.sidebar === true) return undefined; + return config.sidebar.banner; +} + function mergeBanner(existing: unknown, next: ReactNode) { if (!existing) return next; @@ -616,12 +680,14 @@ export function withNextApiReferenceBanner(config: DocsConfig): DocsConfig { const switcher = ( ); + const existingBanner = getExistingSidebarBanner(config); + const banner = mergeBanner(existingBanner, switcher); if (!config.sidebar || config.sidebar === true) { return { ...config, sidebar: { - banner: switcher, + banner, }, }; } @@ -630,7 +696,7 @@ export function withNextApiReferenceBanner(config: DocsConfig): DocsConfig { ...config, sidebar: { ...config.sidebar, - banner: mergeBanner(config.sidebar.banner, switcher), + banner, }, }; } @@ -638,7 +704,7 @@ export function withNextApiReferenceBanner(config: DocsConfig): DocsConfig { export function createNextApiReference(config: DocsConfig) { const apiReference = resolveApiReferenceConfig(config.apiReference); - return async () => { + return async (request?: Request) => { if (!apiReference.enabled) { return new Response("Not Found", { status: 404, @@ -648,6 +714,7 @@ export function createNextApiReference(config: DocsConfig) { const document = await buildApiReferenceOpenApiDocumentAsync(config, { framework: "next", rootDir: process.cwd(), + baseUrl: getOriginFromRequest(request), }); return ApiReference({ @@ -676,3 +743,214 @@ export function createNextApiReference(config: DocsConfig) { })(); }; } + +function getOpenApiInfo(document: Record): { + title: string; + description?: string; +} { + const info = + document.info && typeof document.info === "object" && !Array.isArray(document.info) + ? (document.info as Record) + : {}; + + return { + title: typeof info.title === "string" && info.title.trim() ? info.title : "API Reference", + description: + typeof info.description === "string" && info.description.trim() + ? info.description + : undefined, + }; +} + +export function createNextApiReferencePage(config: DocsConfig) { + return async function NextApiReferencePage(props?: { + params?: Promise<{ slug?: string[] }> | { slug?: string[] }; + }) { + const [{ createAPIPage }, { DocsBody, DocsDescription, DocsPage, DocsTitle }] = + await Promise.all([ + import("fumadocs-openapi/ui"), + import("fumadocs-ui/layouts/notebook/page"), + ]); + const { info, pages, server, source } = await getNextApiReferenceSourceState(config); + const resolvedParams = props?.params ? await props.params : undefined; + const slug = resolvedParams?.slug ?? []; + + if (pages.length === 0) { + return ( + + {info.title} + {info.description} + +
+ No operations were found in the OpenAPI document. +
+
+
+ ); + } + + if (slug.length === 0) { + redirect(pages[0].url); + } + + const page = source.getPage(slug); + if (!page || typeof page.data.getAPIPageProps !== "function") { + notFound(); + } + const APIPage = createAPIPage(server); + const currentPageIndex = pages.findIndex((entry) => entry.url === page.url); + const previousPage = currentPageIndex > 0 ? pages[currentPageIndex - 1] : undefined; + const nextPage = + currentPageIndex >= 0 && currentPageIndex < pages.length - 1 + ? pages[currentPageIndex + 1] + : undefined; + + return ( + + {page.data.title ?? info.title} + + {typeof page.data.description === "string" && page.data.description.trim() + ? page.data.description + : info.description} + + + + + {previousPage || nextPage ? ( + + ) : null} + + ); + }; +} + +export function createNextApiReferenceLayout(config: DocsConfig) { + return async function NextApiReferenceLayout(props: { children: React.ReactNode }) { + const { DocsLayout } = await import("fumadocs-ui/layouts/notebook"); + const { apiReference, source } = await getNextApiReferenceSourceState(config); + const docsUrl = getDocsUrl(config); + const apiUrl = `/${apiReference.path}`; + const banner = mergeBanner( + getExistingSidebarBanner(config), + , + ); + + return ( +
+ + + {props.children} + +
+ ); + }; +} + +export async function getNextApiReferenceSourceState( + config: DocsConfig, +): Promise { + const apiReference = resolveApiReferenceConfig(config.apiReference); + const [{ createOpenAPI, openapiPlugin, openapiSource }, { loader }] = await Promise.all([ + import("fumadocs-openapi/server"), + import("fumadocs-core/source"), + ]); + const baseUrl = await getOriginFromNextHeaders(); + const document = await buildApiReferenceOpenApiDocumentAsync(config, { + framework: "next", + rootDir: process.cwd(), + baseUrl, + }); + + const server = createOpenAPI({ + input: async () => ({ + main: document as any, + }), + }); + const info = getOpenApiInfo(document); + const source = loader( + await openapiSource(server, { + per: "operation", + groupBy: "tag", + name(output, dereferenced) { + if (output.type !== "operation") { + return slugifyApiReferencePageName(output.item.name); + } + + const pathItem = dereferenced.paths?.[output.item.path]; + const operation = pathItem?.[output.item.method]; + const summary = + typeof operation?.summary === "string" && operation.summary.trim() + ? operation.summary + : typeof operation?.operationId === "string" && operation.operationId.trim() + ? operation.operationId + : `${output.item.method} ${output.item.path}`; + + return slugifyApiReferencePageName(summary); + }, + }), + { + baseUrl: `/${apiReference.path}`, + plugins: [openapiPlugin()], + }, + ); + + return { + apiReference, + document, + info, + pages: source.getPages(), + server, + source, + }; +} + +export function getNextApiReferenceMode(config: DocsConfig): "fumadocs" | "scalar" { + return resolveApiReferenceRenderer(config.apiReference, "next"); +} diff --git a/packages/next/src/config.test.ts b/packages/next/src/config.test.ts index 3b3ad494..9b0fbb02 100644 --- a/packages/next/src/config.test.ts +++ b/packages/next/src/config.test.ts @@ -16,6 +16,16 @@ const DOCS_CONFIG_WITH_API_REFERENCE = `export default { }; `; +const DOCS_CONFIG_WITH_SCALAR_API_REFERENCE = `export default { + entry: "docs", + apiReference: { + enabled: true, + path: "api-reference", + renderer: "scalar", + }, +}; +`; + describe("withDocs (app dir: src/app vs app)", () => { let tmpDir: string; let originalCwd: string; @@ -71,14 +81,28 @@ describe("withDocs (app dir: src/app vs app)", () => { expect(existsSync(join(tmpDir, "app/docs/layout.tsx"))).toBe(false); }); - it("generates API reference routes when enabled in docs.config", () => { + it("generates a fumadocs API reference page when enabled in docs.config", () => { writeFileSync(join(tmpDir, "docs.config.ts"), DOCS_CONFIG_WITH_API_REFERENCE, "utf-8"); mkdirSync(join(tmpDir, "app"), { recursive: true }); process.chdir(tmpDir); withDocs({}); + expect(existsSync(join(tmpDir, "app/api-reference/layout.tsx"))).toBe(true); + expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/page.tsx"))).toBe(true); + expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/route.ts"))).toBe(false); + }); + + it("generates a scalar API reference route when renderer is set explicitly", () => { + writeFileSync(join(tmpDir, "docs.config.ts"), DOCS_CONFIG_WITH_SCALAR_API_REFERENCE, "utf-8"); + mkdirSync(join(tmpDir, "app"), { recursive: true }); + process.chdir(tmpDir); + + withDocs({}); + + expect(existsSync(join(tmpDir, "app/api-reference/layout.tsx"))).toBe(false); expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/route.ts"))).toBe(true); + expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/page.tsx"))).toBe(false); }); it("skips API reference route generation for static export", () => { @@ -88,6 +112,8 @@ describe("withDocs (app dir: src/app vs app)", () => { withDocs({ output: "export" }); + expect(existsSync(join(tmpDir, "app/api-reference/layout.tsx"))).toBe(false); + expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/page.tsx"))).toBe(false); expect(existsSync(join(tmpDir, "app/api-reference/[[...slug]]/route.ts"))).toBe(false); }); @@ -110,7 +136,8 @@ describe("withDocs (app dir: src/app vs app)", () => { withDocs({}); - expect(existsSync(join(tmpDir, "app/custom-api-reference/[[...slug]]/route.ts"))).toBe(true); + expect(existsSync(join(tmpDir, "app/custom-api-reference/layout.tsx"))).toBe(true); + expect(existsSync(join(tmpDir, "app/custom-api-reference/[[...slug]]/page.tsx"))).toBe(true); }); it("generates a layout that re-exports the package-owned docs layout", () => { diff --git a/packages/next/src/config.ts b/packages/next/src/config.ts index e73898a5..850c93bf 100644 --- a/packages/next/src/config.ts +++ b/packages/next/src/config.ts @@ -21,8 +21,9 @@ * export default withDocs({ output: "export" }); */ -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; /** Resolve Next.js App Router directory: prefer src/app when present, else app. */ function getNextAppDir(root: string): string { @@ -96,10 +97,93 @@ export const GET = createNextApiReference(docsConfig); export const revalidate = false; `; +const API_REFERENCE_PAGE_TEMPLATE = `\ +${GENERATED_BANNER} +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; +`; + +const API_REFERENCE_LAYOUT_TEMPLATE = `\ +${GENERATED_BANNER} +import docsConfig from "@/docs.config"; +import { createNextApiReferenceLayout } from "@farming-labs/next/api-reference"; + +const ApiReferenceLayout = createNextApiReferenceLayout(docsConfig); + +export default ApiReferenceLayout; +`; + // ─── Helpers ──────────────────────────────────────────────────────── const FILE_EXTS = ["tsx", "ts", "jsx", "js"]; const INTERNAL_DOCS_CONFIG_ALIAS = "@farming-labs/next-internal-docs-config"; +const NEXT_PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +function resolvePackageAlias(packageName: string, fallbacks: string[] = []): string | undefined { + const candidates = [ + join(NEXT_PACKAGE_ROOT, "node_modules", packageName), + ...fallbacks.map((value) => join(NEXT_PACKAGE_ROOT, value)), + ]; + + return candidates.find((value) => existsSync(value)); +} + +function resolvePackageSubpath(packageDir: string, relativePath: string): string { + if (!isAbsolute(packageDir)) + return `${packageDir}/${relativePath.replace(/^dist\//, "").replace(/\/index\.js$/, "")}`; + return join(packageDir, relativePath); +} + +function toTurbopackAliasPath(root: string, value: string): string { + if (!isAbsolute(value)) return value; + const relativePath = relative(root, value); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +const FUMADOCS_OPENAPI_PACKAGE_ALIAS = + resolvePackageAlias("fumadocs-openapi") ?? "fumadocs-openapi"; +const FUMADOCS_CORE_PACKAGE_ALIAS = resolvePackageAlias("fumadocs-core") ?? "fumadocs-core"; +const FUMADOCS_UI_PACKAGE_ALIAS = + resolvePackageAlias("fumadocs-ui", [ + "node_modules/fumadocs-openapi/node_modules/fumadocs-ui", + "../node_modules/fumadocs-ui", + ]) ?? "fumadocs-ui"; +const FUMADOCS_OPENAPI_UI_ALIAS = resolvePackageSubpath( + FUMADOCS_OPENAPI_PACKAGE_ALIAS, + "dist/ui/index.js", +); +const FUMADOCS_OPENAPI_SERVER_ALIAS = resolvePackageSubpath( + FUMADOCS_OPENAPI_PACKAGE_ALIAS, + "dist/server/index.js", +); +const FUMADOCS_CORE_FRAMEWORK_ALIAS = resolvePackageSubpath( + FUMADOCS_CORE_PACKAGE_ALIAS, + "dist/framework/index.js", +); +const FUMADOCS_CORE_FRAMEWORK_NEXT_ALIAS = resolvePackageSubpath( + FUMADOCS_CORE_PACKAGE_ALIAS, + "dist/framework/next.js", +); +const FUMADOCS_UI_NOTEBOOK_ALIAS = resolvePackageSubpath( + FUMADOCS_UI_PACKAGE_ALIAS, + "dist/layouts/notebook/index.js", +); +const FUMADOCS_UI_NOTEBOOK_PAGE_ALIAS = resolvePackageSubpath( + FUMADOCS_UI_PACKAGE_ALIAS, + "dist/layouts/notebook/page/index.js", +); +const FUMADOCS_UI_PROVIDER_NEXT_ALIAS = resolvePackageSubpath( + FUMADOCS_UI_PACKAGE_ALIAS, + "dist/provider/next.js", +); function hasFile(root: string, baseName: string): boolean { return FILE_EXTS.some((ext) => existsSync(join(root, `${baseName}.${ext}`))); @@ -161,6 +245,7 @@ function readOgEndpoint(root: string): string | undefined { function readApiReferenceConfig(root: string): { enabled: boolean; path: string; + renderer: "fumadocs" | "scalar"; routeRoot: string; } { for (const ext of FILE_EXTS) { @@ -171,29 +256,35 @@ function readApiReferenceConfig(root: string): { const content = readFileSync(configPath, "utf-8"); const directFalse = content.match(/apiReference\s*:\s*false/); - if (directFalse) return { enabled: false, path: "api-reference", routeRoot: "api" }; + if (directFalse) { + return { enabled: false, path: "api-reference", renderer: "fumadocs", routeRoot: "api" }; + } const directTrue = content.match(/apiReference\s*:\s*true/); - if (directTrue) return { enabled: true, path: "api-reference", routeRoot: "api" }; + if (directTrue) { + return { enabled: true, path: "api-reference", renderer: "fumadocs", routeRoot: "api" }; + } const block = extractObjectLiteral(content, "apiReference"); if (!block) continue; const enabledMatch = block.match(/enabled\s*:\s*(true|false)/); const pathMatch = block.match(/path\s*:\s*["']([^"']+)["']/); + const rendererMatch = block.match(/renderer\s*:\s*["'](fumadocs|scalar)["']/); const routeRootMatch = block.match(/routeRoot\s*:\s*["']([^"']+)["']/); return { enabled: enabledMatch ? enabledMatch[1] !== "false" : true, path: pathMatch?.[1]?.replace(/^\/+|\/+$/g, "") || "api-reference", + renderer: (rendererMatch?.[1] as "fumadocs" | "scalar" | undefined) ?? "fumadocs", routeRoot: routeRootMatch?.[1]?.replace(/^\/+|\/+$/g, "") || "api", }; } catch { - return { enabled: false, path: "api-reference", routeRoot: "api" }; + return { enabled: false, path: "api-reference", renderer: "fumadocs", routeRoot: "api" }; } } - return { enabled: false, path: "api-reference", routeRoot: "api" }; + return { enabled: false, path: "api-reference", renderer: "fumadocs", routeRoot: "api" }; } function extractObjectLiteral(content: string, key: string): string | undefined { @@ -224,6 +315,12 @@ function extractObjectLiteral(content: string, key: string): string | undefined return undefined; } +function removeManagedFile(filePath: string) { + if (isManagedGeneratedFile(filePath)) { + rmSync(filePath, { force: true }); + } +} + // ─── withDocs ─────────────────────────────────────────────────────── export function withDocs(nextConfig: Record = {}) { @@ -263,13 +360,41 @@ export function withDocs(nextConfig: Record = {}) { writeFileSync(join(docsApiRouteDir, "route.ts"), DOCS_API_ROUTE_TEMPLATE); } - // ── 3.1. Auto-generate app/{apiReference.path}/[[...slug]]/route.ts ── + // ── 3.1. Auto-generate API reference route/page ─────────────────── const apiReference = readApiReferenceConfig(root); if (apiReference.enabled && !isStaticExport) { - const apiReferenceRouteDir = join(root, appDir, ...apiReference.path.split("/"), "[[...slug]]"); - if (!hasFile(apiReferenceRouteDir, "route")) { - mkdirSync(apiReferenceRouteDir, { recursive: true }); - writeFileSync(join(apiReferenceRouteDir, "route.ts"), API_REFERENCE_ROUTE_TEMPLATE); + const apiReferenceBaseDir = join(root, appDir, ...apiReference.path.split("/")); + const apiReferencePageDir = join(apiReferenceBaseDir, "[[...slug]]"); + const apiReferencePagePath = join(apiReferencePageDir, "page.tsx"); + const apiReferenceLayoutPath = join(apiReferenceBaseDir, "layout.tsx"); + const apiReferenceRouteDir = join(apiReferenceBaseDir, "[[...slug]]"); + const apiReferenceRoutePath = join(apiReferenceRouteDir, "route.ts"); + const legacyApiReferencePagePath = join(apiReferenceBaseDir, "page.tsx"); + + if (apiReference.renderer === "fumadocs") { + removeManagedFile(apiReferenceRoutePath); + removeManagedFile(legacyApiReferencePagePath); + if ( + !hasFile(apiReferenceBaseDir, "layout") || + isManagedGeneratedFile(apiReferenceLayoutPath) + ) { + mkdirSync(apiReferenceBaseDir, { recursive: true }); + writeFileSync(apiReferenceLayoutPath, API_REFERENCE_LAYOUT_TEMPLATE); + } + if (!hasFile(apiReferencePageDir, "page") || isManagedGeneratedFile(apiReferencePagePath)) { + mkdirSync(apiReferencePageDir, { recursive: true }); + writeFileSync(apiReferencePagePath, API_REFERENCE_PAGE_TEMPLATE); + } + } else { + removeManagedFile(apiReferenceLayoutPath); + removeManagedFile(apiReferencePagePath); + if ( + !hasFile(apiReferenceRouteDir, "route") || + isManagedGeneratedFile(apiReferenceRoutePath) + ) { + mkdirSync(apiReferenceRouteDir, { recursive: true }); + writeFileSync(apiReferenceRoutePath, API_REFERENCE_ROUTE_TEMPLATE); + } } } @@ -322,6 +447,22 @@ export function withDocs(nextConfig: Record = {}) { resolveAlias: { ...existingResolveAlias, [INTERNAL_DOCS_CONFIG_ALIAS]: docsConfigRelativeAlias, + "fumadocs-openapi": toTurbopackAliasPath(root, FUMADOCS_OPENAPI_PACKAGE_ALIAS), + "fumadocs-openapi/ui": toTurbopackAliasPath(root, FUMADOCS_OPENAPI_UI_ALIAS), + "fumadocs-openapi/server": toTurbopackAliasPath(root, FUMADOCS_OPENAPI_SERVER_ALIAS), + "fumadocs-core": toTurbopackAliasPath(root, FUMADOCS_CORE_PACKAGE_ALIAS), + "fumadocs-core/framework": toTurbopackAliasPath(root, FUMADOCS_CORE_FRAMEWORK_ALIAS), + "fumadocs-core/framework/next": toTurbopackAliasPath( + root, + FUMADOCS_CORE_FRAMEWORK_NEXT_ALIAS, + ), + "fumadocs-ui": toTurbopackAliasPath(root, FUMADOCS_UI_PACKAGE_ALIAS), + "fumadocs-ui/layouts/notebook": toTurbopackAliasPath(root, FUMADOCS_UI_NOTEBOOK_ALIAS), + "fumadocs-ui/layouts/notebook/page": toTurbopackAliasPath( + root, + FUMADOCS_UI_NOTEBOOK_PAGE_ALIAS, + ), + "fumadocs-ui/provider/next": toTurbopackAliasPath(root, FUMADOCS_UI_PROVIDER_NEXT_ALIAS), }, }; @@ -334,6 +475,18 @@ export function withDocs(nextConfig: Record = {}) { resolvedConfig.resolve ??= {}; resolvedConfig.resolve.alias ??= {}; resolvedConfig.resolve.alias[INTERNAL_DOCS_CONFIG_ALIAS] = docsConfigAbsolutePath; + resolvedConfig.resolve.alias["fumadocs-openapi"] = FUMADOCS_OPENAPI_PACKAGE_ALIAS; + resolvedConfig.resolve.alias["fumadocs-openapi/ui"] = FUMADOCS_OPENAPI_UI_ALIAS; + resolvedConfig.resolve.alias["fumadocs-openapi/server"] = FUMADOCS_OPENAPI_SERVER_ALIAS; + resolvedConfig.resolve.alias["fumadocs-core"] = FUMADOCS_CORE_PACKAGE_ALIAS; + resolvedConfig.resolve.alias["fumadocs-core/framework"] = FUMADOCS_CORE_FRAMEWORK_ALIAS; + resolvedConfig.resolve.alias["fumadocs-core/framework/next"] = + FUMADOCS_CORE_FRAMEWORK_NEXT_ALIAS; + resolvedConfig.resolve.alias["fumadocs-ui"] = FUMADOCS_UI_PACKAGE_ALIAS; + resolvedConfig.resolve.alias["fumadocs-ui/layouts/notebook"] = FUMADOCS_UI_NOTEBOOK_ALIAS; + resolvedConfig.resolve.alias["fumadocs-ui/layouts/notebook/page"] = + FUMADOCS_UI_NOTEBOOK_PAGE_ALIAS; + resolvedConfig.resolve.alias["fumadocs-ui/provider/next"] = FUMADOCS_UI_PROVIDER_NEXT_ALIAS; return resolvedConfig; }; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index a6db7780..a52ed3dc 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -2,6 +2,10 @@ export { withDocs } from "./config.js"; export { buildNextOpenApiDocument, createNextApiReference, + createNextApiReferenceLayout, + createNextApiReferencePage, + getNextApiReferenceSourceState, + getNextApiReferenceMode, resolveApiReferenceConfig, withNextApiReferenceBanner, } from "./api-reference.js"; diff --git a/packages/next/src/react-dom-server.d.ts b/packages/next/src/react-dom-server.d.ts new file mode 100644 index 00000000..bc5a315b --- /dev/null +++ b/packages/next/src/react-dom-server.d.ts @@ -0,0 +1,3 @@ +declare module "react-dom/server" { + export function renderToStaticMarkup(element: React.ReactNode): string; +} diff --git a/packages/next/styles/api-reference.css b/packages/next/styles/api-reference.css new file mode 100644 index 00000000..275f1a1a --- /dev/null +++ b/packages/next/styles/api-reference.css @@ -0,0 +1,146 @@ +@import "tailwindcss"; +@import "fumadocs-ui/css/solar.css"; +@import "fumadocs-ui/css/preset.css"; +@import "fumadocs-openapi/css/preset.css"; + +.fd-api-reference-route { + --fd-layout-width: 97rem; + --color-fd-background: hsl(0 0% 7.04%); + --color-fd-foreground: hsl(0 0% 92%); + --color-fd-muted: hsl(0 0% 12.9%); + --color-fd-muted-foreground: hsl(0 0% 70% / 0.8); + --color-fd-popover: hsl(0 0% 11.6%); + --color-fd-popover-foreground: hsl(0 0% 86.9%); + --color-fd-card: hsl(0 0% 9.8%); + --color-fd-card-foreground: hsl(0 0% 98%); + --color-fd-border: hsl(0 0% 40% / 0.2); + --color-fd-secondary: hsl(0 0% 12.9%); + --color-fd-secondary-foreground: hsl(0 0% 92%); + --color-fd-accent: hsl(0 0% 40.9% / 0.3); + --color-fd-accent-foreground: hsl(0 0% 90%); + --color-fd-primary: oklch(0.902 0.0461 259.51); + --color-fd-primary-foreground: hsl(0 0% 9%); + --color-fd-ring: hsl(0 0% 54.9%); + --color-fd-article: hsl(0 0% 7.04%); + letter-spacing: -0.25px; + color-scheme: dark; +} + +.fd-api-reference-route #nd-subnav { + display: none; +} + +.fd-api-reference-route #nd-notebook-layout { + --fd-header-height: 0px !important; + background: var(--color-fd-background); +} + +@media (min-width: 768px) { + .fd-api-reference-route #nd-notebook-layout { + grid-template: + "sidebar header toc" + "sidebar main toc" 1fr / + minmax(var(--fd-sidebar-col), 1fr) + minmax( + 0, + calc(var(--fd-layout-width, 97rem) - var(--fd-sidebar-width) - var(--fd-toc-width)) + ) + minmax(min-content, 1fr) !important; + } +} + +.fd-api-reference-route #nd-notebook-layout, +.fd-api-reference-route #nd-page, +.fd-api-reference-route [grid-area="main"] { + background: var(--color-fd-background); +} + +.fd-api-reference-route #nd-sidebar[data-collapsed="false"] { + background: var(--color-fd-background); + border-inline-end: 1px solid color-mix(in srgb, var(--color-fd-border) 95%, transparent); + box-shadow: inset -1px 0 0 color-mix(in srgb, var(--color-fd-border) 45%, transparent); +} + +.fd-api-reference-route #nd-page.fd-api-reference-page > * { + width: 100%; + max-width: 1180px; +} + +.fd-api-reference-route #nd-page.fd-api-reference-page > .prose { + max-width: 1180px; +} + +.fd-api-reference-pagination { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1rem; +} + +.fd-api-reference-pagination-item { + display: flex; + min-height: 8.5rem; + flex-direction: column; + gap: 0.45rem; + border: 1px solid var(--color-fd-border); + border-radius: 1rem; + background: var(--color-fd-card); + padding: 1.1rem 1.25rem; + text-decoration: none; + color: inherit; + transition: + border-color 150ms ease, + background-color 150ms ease, + transform 150ms ease; +} + +.fd-api-reference-pagination-item:hover { + border-color: color-mix(in srgb, var(--color-fd-primary) 30%, var(--color-fd-border)); + background: color-mix(in srgb, var(--color-fd-accent) 45%, var(--color-fd-card)); + transform: translateY(-1px); +} + +.fd-api-reference-pagination-item[data-direction="next"] { + text-align: right; +} + +.fd-api-reference-pagination-label { + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-fd-muted-foreground); +} + +.fd-api-reference-pagination-title { + font-size: 1.05rem; + font-weight: 600; + color: var(--color-fd-foreground); +} + +.fd-api-reference-pagination-description { + color: var(--color-fd-muted-foreground); + font-size: 0.92rem; + line-height: 1.5; +} + +.fd-api-reference-pagination-spacer { + display: none; +} + +@media (min-width: 768px) { + .fd-api-reference-route #nd-notebook-layout #nd-page { + padding-inline: 1.5rem; + padding-top: 2rem; + } + + .fd-api-reference-route #nd-notebook-layout #nd-page::before { + display: none; + } +} + +@media (max-width: 1023px) { + .fd-api-reference-pagination { + grid-template-columns: 1fr; + } +} diff --git a/packages/next/tsconfig.json b/packages/next/tsconfig.json index 8f5deb50..9b278ad2 100644 --- a/packages/next/tsconfig.json +++ b/packages/next/tsconfig.json @@ -12,7 +12,9 @@ "skipLibCheck": true, "types": ["node", "react"], "paths": { - "@farming-labs/docs": ["../docs/src/index.ts"] + "@farming-labs/docs": ["../docs/src/index.ts"], + "@farming-labs/docs/server": ["../docs/src/server.ts"], + "@farming-labs/theme/client-hooks": ["../fumadocs/src/docs-client-hooks.tsx"] }, "noEmit": true }, diff --git a/packages/next/tsdown.config.ts b/packages/next/tsdown.config.ts index 0e16502e..9b5970db 100644 --- a/packages/next/tsdown.config.ts +++ b/packages/next/tsdown.config.ts @@ -26,6 +26,10 @@ export default defineConfig({ "@mdx-js/loader", "@mdx-js/react", "fumadocs-core", + "fumadocs-openapi", + "fumadocs-openapi/*", + "fumadocs-ui", + "fumadocs-ui/*", "remark-gfm", "remark-frontmatter", "remark-mdx-frontmatter", diff --git a/packages/next/vitest.config.ts b/packages/next/vitest.config.ts index c497b8ec..a1b8e37c 100644 --- a/packages/next/vitest.config.ts +++ b/packages/next/vitest.config.ts @@ -1,6 +1,16 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const rootDir = dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + resolve: { + alias: { + "@farming-labs/docs/server": resolve(rootDir, "../docs/src/server.ts"), + "@farming-labs/docs": resolve(rootDir, "../docs/src/index.ts"), + }, + }, test: { include: ["src/**/*.test.ts"], globals: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f370025d..029726ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -314,10 +314,10 @@ importers: dependencies: fumadocs-core: specifier: ^16.6.1 - version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) fumadocs-ui: specifier: ^16.6.1 - version: 16.6.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + version: 16.6.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -372,7 +372,10 @@ importers: version: 0.10.3(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: ^16.6.1 - version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-openapi: + specifier: 10.3.6 + version: 10.3.6(yqk7lfb2y7xrvp3mgs2mflp2pe) next: specifier: '>=16.0.0' version: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -504,7 +507,7 @@ importers: version: 0.4.3 fumadocs-core: specifier: ^16.6.1 - version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + version: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -1221,6 +1224,28 @@ packages: tailwindcss: optional: true + '@fumari/json-schema-to-typescript@2.0.0': + resolution: {integrity: sha512-X0Wm3QJLj1Rtb1nY2exM6QwMXb9LGyIKLf35+n6xyltDDBLMECOC4R/zPaw3RwgFVmvRLSmLCd+ht4sKabgmNw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@apidevtools/json-schema-ref-parser': 14.x.x + prettier: 3.x.x + peerDependenciesMeta: + '@apidevtools/json-schema-ref-parser': + optional: true + prettier: + optional: true + + '@fumari/stf@0.0.3': + resolution: {integrity: sha512-EDgfqz6oWJLPfmrekl4sCssypPmQ1dV8J7RqWR9Wbzj2mekUIeAqljGkh1EgykZp8Yve9Ehnmn3gjHggFgQU2A==} + peerDependencies: + '@types/react': '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@img/colour@1.0.0': resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} @@ -2565,6 +2590,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2984,10 +3022,26 @@ packages: resolution: {integrity: sha512-Fp/J5RyajknwZrDG5/nsJRxL07cS2gJ9wvnvIWcFgsR8HWy/7qMQ+PtpYhdBmMYiBAql1JJobKTu/7ZWqnHaFA==} engines: {node: '>=22'} + '@scalar/helpers@0.2.12': + resolution: {integrity: sha512-Ig/H1Je8nqcDiY+YwFIpATxF2ko7zKrjIZFWK2gGeNTYK4Np9XnqDHg56jM3Xru439Eh4qHq9P/lX7Se5nnxFA==} + engines: {node: '>=20'} + + '@scalar/helpers@0.2.18': + resolution: {integrity: sha512-w1d4tpNEVZ293oB2BAgLrS0kVPUtG3eByNmOCJA5eK9vcT4D3cmsGtWjUaaqit0BQCsBFHK51rasGvSWnApYTw==} + engines: {node: '>=20'} + '@scalar/helpers@0.4.1': resolution: {integrity: sha512-XfyYqSUA597wfzS8lmMY1xvYv4si2WytuoBWj7JDpx3E/lVq7YEsticXF/Q30ttFVRBfgQErg8MQI6b6IkZr2Q==} engines: {node: '>=22'} + '@scalar/json-magic@0.11.1': + resolution: {integrity: sha512-JsugkVpZ9SmKW6fDhamcmkttc9YOPGgb9Azbwc7hXTlZgG6YeYXx8qFvYr5eJE4cfzCqalodS/9w7moZnVG3cw==} + engines: {node: '>=20'} + + '@scalar/json-magic@0.11.7': + resolution: {integrity: sha512-GVz9E0vXu+ecypkdn0biK1gbQVkK4QTTX1Hq3eMgxlLQC91wwiqWfCqwfhuX0LRu+Z5OmYhLhufDJEEh56rVgA==} + engines: {node: '>=20'} + '@scalar/nextjs-api-reference@0.10.3': resolution: {integrity: sha512-Ewar7YWavvMU5W1YUCX2zXisQVURVtNG2b9AhEN6QGqWUtIJ/C+cOqOtyWGF8iVXbYGIsOyFASKN+GOhdoQMJQ==} engines: {node: '>=22'} @@ -2995,6 +3049,26 @@ packages: next: ^15.0.0 || ^16.0.0 react: ^19.0.0 + '@scalar/openapi-parser@0.24.10': + resolution: {integrity: sha512-E9K8OYD7XKHsvTyLTSdILKHbm4Q3n/MA3EGdDTEBLJHSJd1vLOwiJzrp3+h+xiqFxlX7vlecInZvFy/3c1fqPg==} + engines: {node: '>=20'} + + '@scalar/openapi-types@0.5.3': + resolution: {integrity: sha512-m4n/Su3K01d15dmdWO1LlqecdSPKuNjuokrJLdiQ485kW/hRHbXW1QP6tJL75myhw/XhX5YhYAR+jrwnGjXiMw==} + engines: {node: '>=20'} + + '@scalar/openapi-types@0.5.4': + resolution: {integrity: sha512-2pEbhprh8lLGDfUI6mNm9EV104pjb3+aJsXrFaqfgOSre7r6NlgM5HcSbsLjzDAnTikjJhJ3IMal1Rz8WVwiOw==} + engines: {node: '>=20'} + + '@scalar/openapi-upgrader@0.1.11': + resolution: {integrity: sha512-ngJcHGoCHmpWgYtNy08vmzFfLdQEkMpvaCQqNPPMNKq0QEXOv89e/rn+TZJZgPnRlY7fDIoIhn9lNgr+azBW+w==} + engines: {node: '>=20'} + + '@scalar/openapi-upgrader@0.1.8': + resolution: {integrity: sha512-2xuYLLs0fBadLIk4I1ObjMiCnOyLPEMPf24A1HtHQvhKGDnGlvT63F2rU2Xw8lxCjgHnzveMPnOJEbwIy64RCg==} + engines: {node: '>=20'} + '@scalar/types@0.7.3': resolution: {integrity: sha512-9ThydMH28aA5DMm8Q/nTKXKnAKejG7Awlj7IC99oXkq5aOJ60Sn0kS1RWaqQosaWI3ezyPJ7/sUglssAEMA6EA==} engines: {node: '>=22'} @@ -3405,6 +3479,9 @@ packages: '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3656,9 +3733,28 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + alien-signals@3.1.2: resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} @@ -4548,6 +4644,16 @@ packages: fast-npm-meta@1.2.1: resolution: {integrity: sha512-vTHOCEbzcbQEfYL0sPzcz+HF5asxoy60tPBVaiYzsCfuyhbXZCSqXL+LgPGV22nuAYimoGMeDpywMQB4aOw8HQ==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.9: + resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4584,6 +4690,9 @@ packages: resolution: {integrity: sha512-piJxbLnkD9Xcyi7dWJRnqszEURixe7CrF/efBfbffe2DPyabmuIuqraruY8cXTs19QoM8VJzx47BDRVNXETM7Q==} engines: {node: '>=20'} + foreach@2.0.6: + resolution: {integrity: sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -4693,6 +4802,24 @@ packages: zod: optional: true + fumadocs-openapi@10.3.6: + resolution: {integrity: sha512-mihOR2WpTtq8Hy37EjmkRWNApiJNWzB8AJN6cFP0q5nZ+DDKoCrMr3mVcVZJ1NCmCyaC1I4iZAxg8MSR89CcOQ==} + peerDependencies: + '@scalar/api-client-react': '*' + '@types/react': '*' + fumadocs-core: ^16.5.0 + fumadocs-ui: ^16.5.0 + json-schema-typed: '*' + react: ^19.2.0 + react-dom: ^19.2.0 + peerDependenciesMeta: + '@scalar/api-client-react': + optional: true + '@types/react': + optional: true + json-schema-typed: + optional: true + fumadocs-ui@16.6.1: resolution: {integrity: sha512-rwms1m/0c5uykFDWA3e29ZyBHG+JXSeCF6QBKZEz5MqOxPfOrFNk0q8yyqaf9lkGldP/PRegR3a8/6+PB5tn0w==} peerDependencies: @@ -5089,9 +5216,15 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-pointer@0.6.2: + resolution: {integrity: sha512-vLWcKbOaXlO+jvRy4qNd+TI1QUPZzfJj1tpJ3vAXDych5XJf93ftpUKe5pKCrzyIIwgBJcOcCVRUfqQP25afBw==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -5100,6 +5233,10 @@ packages: jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -5126,6 +5263,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + leven@4.1.0: + resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -5329,6 +5470,11 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lucide-react@0.570.0: + resolution: {integrity: sha512-qGnQ8bEPJLMseKo7kI6jK6GW6Y2Yl4PpqoWbroNsobZ8+tZR4SUuO4EXK3oWCdZr48SZ7PnaulTkvzkKvG/Iqg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -5789,6 +5935,9 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + openapi-sampler@1.7.2: + resolution: {integrity: sha512-OKytvqB5XIaTgA9xtw8W8UTar+uymW2xPVpFN0NihMtuHPdPTGxBEhGnfFnJW5g/gOSIvkP+H0Xh3XhVI9/n7g==} + oxc-minify@0.112.0: resolution: {integrity: sha512-rkVSeeIRSt+RYI9uX6xonBpLUpvZyegxIg0UL87ev7YAfUqp7IIZlRjkgQN5Us1lyXD//TOo0Dcuuro/TYOWoQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5877,6 +6026,10 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -6228,6 +6381,12 @@ packages: peerDependencies: react: ^19.2.4 + react-hook-form@7.72.0: + resolution: {integrity: sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-medium-image-zoom@5.4.0: resolution: {integrity: sha512-BsE+EnFVQzFIlyuuQrZ9iTwyKpKkqdFZV1ImEQN573QPqGrIUuNni7aF+sZwDcxlsuOMayCr6oO/PZR/yJnbRg==} peerDependencies: @@ -6380,6 +6539,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -6692,6 +6855,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + structured-clone-es@1.0.0: resolution: {integrity: sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==} @@ -6755,6 +6921,9 @@ packages: tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} @@ -7478,6 +7647,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -8097,6 +8270,19 @@ snapshots: optionalDependencies: tailwindcss: 4.2.1 + '@fumari/json-schema-to-typescript@2.0.0(prettier@3.8.1)': + dependencies: + js-yaml: 4.1.1 + optionalDependencies: + prettier: 3.8.1 + + '@fumari/stf@0.0.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@img/colour@1.0.0': optional: true @@ -9375,6 +9561,35 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -9679,14 +9894,58 @@ snapshots: dependencies: '@scalar/types': 0.7.3 + '@scalar/helpers@0.2.12': {} + + '@scalar/helpers@0.2.18': {} + '@scalar/helpers@0.4.1': {} + '@scalar/json-magic@0.11.1': + dependencies: + '@scalar/helpers': 0.2.12 + yaml: 2.8.2 + + '@scalar/json-magic@0.11.7': + dependencies: + '@scalar/helpers': 0.2.18 + pathe: 2.0.3 + yaml: 2.8.2 + '@scalar/nextjs-api-reference@0.10.3(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': dependencies: '@scalar/core': 0.4.3 next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 + '@scalar/openapi-parser@0.24.10': + dependencies: + '@scalar/helpers': 0.2.12 + '@scalar/json-magic': 0.11.1 + '@scalar/openapi-types': 0.5.3 + '@scalar/openapi-upgrader': 0.1.8 + ajv: 8.18.0 + ajv-draft-04: 1.0.0(ajv@8.18.0) + ajv-formats: 3.0.1(ajv@8.18.0) + jsonpointer: 5.0.1 + leven: 4.1.0 + yaml: 2.8.2 + + '@scalar/openapi-types@0.5.3': + dependencies: + zod: 4.3.6 + + '@scalar/openapi-types@0.5.4': + dependencies: + zod: 4.3.6 + + '@scalar/openapi-upgrader@0.1.11': + dependencies: + '@scalar/openapi-types': 0.5.4 + + '@scalar/openapi-upgrader@0.1.8': + dependencies: + '@scalar/openapi-types': 0.5.3 + '@scalar/types@0.7.3': dependencies: '@scalar/helpers': 0.4.1 @@ -10177,6 +10436,8 @@ snapshots: '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -10512,6 +10773,14 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -10520,6 +10789,13 @@ snapshots: uri-js: 4.4.1 optional: true + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + alien-signals@3.1.2: {} ansi-align@3.0.1: @@ -11530,8 +11806,7 @@ snapshots: dependencies: pure-rand: 6.1.0 - fast-deep-equal@3.1.3: - optional: true + fast-deep-equal@3.1.3: {} fast-fifo@1.3.2: {} @@ -11548,6 +11823,18 @@ snapshots: fast-npm-meta@1.2.1: {} + fast-uri@3.1.0: {} + + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.2.0 + + fast-xml-parser@5.5.9: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.0 + strnum: 2.2.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -11580,6 +11867,8 @@ snapshots: dependencies: tiny-inflate: 1.0.3 + foreach@2.0.6: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -11615,7 +11904,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): + fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6): dependencies: '@formatjs/intl-localematcher': 0.8.1 '@orama/orama': 3.1.18 @@ -11647,7 +11936,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 '@types/react': 19.2.14 - lucide-react: 0.564.0(react@19.2.4) + lucide-react: 0.570.0(react@19.2.4) next: 16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11655,7 +11944,43 @@ snapshots: transitivePeerDependencies: - supports-color - fumadocs-ui@16.6.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): + fumadocs-openapi@10.3.6(yqk7lfb2y7xrvp3mgs2mflp2pe): + dependencies: + '@fumari/json-schema-to-typescript': 2.0.0(prettier@3.8.1) + '@fumari/stf': 0.0.3(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@scalar/json-magic': 0.11.7 + '@scalar/openapi-parser': 0.24.10 + '@scalar/openapi-upgrader': 0.1.11 + ajv: 8.18.0 + class-variance-authority: 0.7.1 + fumadocs-core: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-ui: 16.6.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1) + github-slugger: 2.0.0 + hast-util-to-jsx-runtime: 2.3.6 + js-yaml: 4.1.1 + lucide-react: 0.570.0(react@19.2.4) + next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + openapi-sampler: 1.7.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-hook-form: 7.72.0(react@19.2.4) + remark: 15.0.1 + remark-rehype: 11.1.2 + tailwind-merge: 3.5.0 + xml-js: 1.6.11 + optionalDependencies: + '@types/react': 19.2.14 + transitivePeerDependencies: + - '@apidevtools/json-schema-ref-parser' + - '@types/react-dom' + - prettier + - supports-color + + fumadocs-ui@16.6.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(fumadocs-core@16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.2.1): dependencies: '@fumadocs/tailwind': 0.0.2(tailwindcss@4.2.1) '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -11669,7 +11994,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.564.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + fumadocs-core: 16.6.1(@mdx-js/mdx@3.1.1)(@tanstack/react-router@1.167.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.570.0(react@19.2.4))(next@16.1.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) lucide-react: 0.563.0(react@19.2.4) motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -12143,13 +12468,21 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-pointer@0.6.2: + dependencies: + foreach: 2.0.6 + json-schema-traverse@0.4.1: optional: true + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} jsonc-parser@3.3.1: {} + jsonpointer@5.0.1: {} + kind-of@6.0.3: {} kleur@3.0.3: {} @@ -12169,6 +12502,8 @@ snapshots: dependencies: readable-stream: 2.3.8 + leven@4.1.0: {} + lightningcss-android-arm64@1.30.2: optional: true @@ -12332,6 +12667,10 @@ snapshots: dependencies: react: 19.2.4 + lucide-react@0.570.0(react@19.2.4): + dependencies: + react: 19.2.4 + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -13253,6 +13592,12 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + openapi-sampler@1.7.2: + dependencies: + '@types/json-schema': 7.0.15 + fast-xml-parser: 5.5.9 + json-pointer: 0.6.2 + oxc-minify@0.112.0: optionalDependencies: '@oxc-minify/binding-android-arm-eabi': 0.112.0 @@ -13455,6 +13800,8 @@ snapshots: path-browserify@1.0.1: {} + path-expression-matcher@1.2.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -13799,6 +14146,10 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-hook-form@7.72.0(react@19.2.4): + dependencies: + react: 19.2.4 + react-medium-image-zoom@5.4.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -14030,6 +14381,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -14405,6 +14758,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.2.2: {} + structured-clone-es@1.0.0: {} style-to-js@1.1.21: @@ -14471,6 +14826,8 @@ snapshots: tailwind-merge@3.4.0: {} + tailwind-merge@3.5.0: {} + tailwindcss@4.1.18: {} tailwindcss@4.2.1: {} @@ -15190,6 +15547,10 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-js@1.6.11: + dependencies: + sax: 1.5.0 + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 diff --git a/website/app/docs/configuration/page.mdx b/website/app/docs/configuration/page.mdx index a45fd722..0ae1c0cf 100644 --- a/website/app/docs/configuration/page.mdx +++ b/website/app/docs/configuration/page.mdx @@ -189,6 +189,7 @@ export default defineDocs({ apiReference: { enabled: true, path: "api-reference", + renderer: "fumadocs", routeRoot: "api", exclude: ["/api/internal/health", "internal/debug"], }, @@ -205,6 +206,7 @@ export default defineDocs({ apiReference: { enabled: true, path: "api-reference", + renderer: "fumadocs", specUrl: "https://petstore3.swagger.io/api/v3/openapi.json", }, theme: fumadocs(), @@ -215,13 +217,20 @@ export default defineDocs({ | ----------- | ---------- | ----------------- | ----------- | | `enabled` | `boolean` | `true` inside the object | Enables generated API reference pages | | `path` | `string` | `"api-reference"` | URL path where the generated reference lives | -| `specUrl` | `string` | — | Absolute URL to a hosted OpenAPI JSON document. When set, local route scanning is skipped | +| `renderer` | `"fumadocs" \| "scalar"` | framework-specific | UI renderer. Defaults to `fumadocs` in Next.js and `scalar` in TanStack Start, SvelteKit, Astro, and Nuxt | +| `specUrl` | `string` | — | URL to a hosted OpenAPI JSON document. Supports absolute URLs and request-relative paths in Next.js. When set, local route scanning is skipped | | `routeRoot` | `string` | `"api"` | Filesystem route root to scan. Bare values like `"api"` resolve inside `app/` or `src/app/`; full values like `"app/internal-api"` are supported too | | `exclude` | `string[]` | `[]` | Routes to omit from the generated reference. Accepts URL-style paths like `"/api/hello"` or route-root-relative entries like `"hello"` / `"hello/route.ts"` | When `specUrl` is set, `routeRoot` and `exclude` are ignored because the reference is rendered from the remote spec. +Renderer notes: + +- **Next.js** defaults to the Fumadocs OpenAPI UI and auto-generates the page route with `withDocs()` +- **TanStack Start**, **SvelteKit**, **Astro**, and **Nuxt** default to Scalar because they currently serve the API reference through HTML handlers +- Set `renderer: "scalar"` in Next.js if you want to keep the old Scalar UI + That does not change the framework routing requirements: - **Next.js** still generates the API reference route automatically with `withDocs()`