Files
Alberto Schiabel 16fa3d963a chore(docs): migrate the docs site to Fumadocs 11 (#3956)
## Summary

- upgrades `fumadocs-openapi` 10 → 11, `fumadocs-mdx` 14 → 15, and
`fumadocs-core` / `fumadocs-ui` 16.4 → 16.13
- migrates the Fumadocs OpenAPI API while preserving the custom schema
renderer
- restores local `$ref` resolution in both the visible API schema UI and
generated LLM markdown
- reduces API-reference client payloads by slicing the bundled OpenAPI
document to each page's reachable operations and components
- restores required badges for GET parameters
- normalizes the OpenAPI `no_auth` sentinel so explicitly public
endpoints render without authentication
- moves to `getOpenAPIPageProps()` / `OpenAPIPageProps` and removes
obsolete CSS overrides

## Correctness fixes

Fumadocs 11 changed the page contract from a server-resolved document id
to a client-side bundled document. That exposed several silent
regressions:

- **Reference resolution:** bundled documents retain local `$ref`s. The
LLM renderer now dereferences them, including alias chains and cycles,
while the custom schema renderer uses Fumadocs' resolver and retains raw
reference identity for stable deduplication.
- **Dereference reuse:** repeated LLM-page requests reuse the
dereferenced copy for each cached bundled document instead of walking
the complete spec per page.
- **Client payload size:** each API page now receives only its selected
operations and transitively reachable components. The slicer falls back
to the complete document for non-component pointers, deep component
pointers, missing operations, or dangling references.
- **Required badges:** `readOnly` cannot distinguish GET inputs from
responses. The renderer now uses the page hook's client name to identify
responses.
- **Recursive rendering:** schema markdown rendering now caps both
structural recursion and nested array type rendering.
- **No-auth normalization:** the undeclared `no_auth` sentinel is
removed without discarding any real security alternatives that may
accompany it.
- **Contract drift:** code consuming `getSchema()` now treats `bundled`
as required, matching the upstream type.

Review follow-up also replaces the new OpenAPI `any` types with typed
Fumadocs page props and a narrow recursive schema model. Historical
Fumadocs 10/11 migration explanations live here in the PR, not as
version-specific source comments; source comments retain only durable
invariants.

## Payload impact

| | before | after |
| --- | --- | --- |
| bundled document | 451 KB | 7.7 KB avg / 30 KB worst |
| served page HTML | 693 KB | 198 KB |
| 10-page sample | 6.55 MB | 2.02 MB (69% smaller) |

## Verification

- `bun install --frozen-lockfile`
- `bun run test` — 89 pass
- `bun run lint:links` — 0 errors
- `bun run lint` — 0 errors (77 existing warnings)
- `bun run types:check`
- `bun run build`
- production server + `bun run test:integration` — 74 pass, including
v3.1/v3 API pages, redirects, search, and LLM endpoints

The production build has one existing Turbopack NFT tracing warning from
`next.config.mjs`; it does not fail the build.

## Production vs preview checks

A live sample comparison between [production](https://docs.composio.dev)
and the [PR
preview](https://docs-git-chore-docs-fumadocs-11.preview.composio.dev)
found no docs regression:

- all 14 representative routes returned 200 with matching titles,
headings, canonical production URLs, and key content
- redirects for `/`, `/api-reference`, `/tools`, and `/docs/welcome`
matched exactly
- the sampled pages exposed the same 1,137 internal-link targets; a
balanced sample of 29 links resolved successfully on both deployments
- sampled v3 and v3.1 OpenAPI pages retained endpoint paths, required
fields, response schemas, and legacy indicators
- the generated OpenAPI LLM page was byte-for-byte identical
- selecting TypeScript in a hydrated browser rendered both inactive-tab
examples and synchronized the language tab groups
- `llms.txt` retained the same 139 unique lines in a different order
- `/docs/quickstart.md` only added an explicit `[#next]` heading anchor

The sampled OpenAPI HTML was roughly 35–42% smaller in the preview,
consistent with document slicing rather than missing rendered content.
2026-07-28 15:26:57 +05:30

192 lines
6.0 KiB
TypeScript

// Fumadocs passes its bundled OpenAPI document to a client component. This
// module trims that payload to the operations or webhooks on one reference page.
import type { OpenAPIPageProps } from 'fumadocs-openapi/ui';
export interface PageOperation {
path: string;
method: string;
}
export interface PageWebhook {
name: string;
method: string;
}
const HTTP_METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
// Path-item keys that must travel with a kept operation.
const SHARED_PATH_ITEM_KEYS = ['parameters', 'servers', 'summary', 'description'];
interface SliceableDocument {
paths?: Record<string, object | undefined>;
webhooks?: Record<string, object | undefined>;
components?: {
securitySchemes?: Record<string, unknown>;
};
}
function decodePointerSegment(segment: string): string {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
function resolvePointer(document: unknown, ref: string): unknown {
let node = document;
for (const segment of ref.slice(2).split('/')) {
if (node === null || typeof node !== 'object' || Array.isArray(node)) return undefined;
node = (node as Record<string, unknown>)[decodePointerSegment(segment)];
}
return node;
}
/**
* Collects every `#/components/...` pointer reachable from `node`, following
* references transitively. Returns null if a non-component internal reference
* is found, signalling the caller to keep the whole document.
*/
function collectComponentRefs(
document: unknown,
node: unknown,
found: Set<string>
): Set<string> | null {
if (Array.isArray(node)) {
for (const item of node) {
if (!collectComponentRefs(document, item, found)) return null;
}
return found;
}
if (node === null || typeof node !== 'object') return found;
for (const [key, value] of Object.entries(node)) {
if (key === '$ref' && typeof value === 'string') {
// External references are resolved before bundling; leave them alone.
if (!value.startsWith('#/')) continue;
if (!value.startsWith('#/components/')) return null;
if (found.has(value)) continue;
found.add(value);
if (!collectComponentRefs(document, resolvePointer(document, value), found)) {
return null;
}
continue;
}
if (!collectComponentRefs(document, value, found)) return null;
}
return found;
}
function pickOperations(
pathItem: object,
methods: Set<string>
): Record<string, unknown> | undefined {
const kept: Record<string, unknown> = {};
let matched = false;
for (const [key, value] of Object.entries(pathItem)) {
if (HTTP_METHODS.has(key.toLowerCase())) {
if (!methods.has(key.toLowerCase())) continue;
kept[key] = value;
matched = true;
continue;
}
if (SHARED_PATH_ITEM_KEYS.includes(key)) kept[key] = value;
}
return matched ? kept : undefined;
}
function groupByKey<T extends { method: string }>(
entries: T[],
keyOf: (entry: T) => string
): Map<string, Set<string>> {
const grouped = new Map<string, Set<string>>();
for (const entry of entries) {
const key = keyOf(entry);
const methods = grouped.get(key) ?? new Set<string>();
methods.add(entry.method.toLowerCase());
grouped.set(key, methods);
}
return grouped;
}
/**
* Returns a self-contained document containing the selected operations or
* webhooks and every component they reference transitively.
*
* When the requested operation is missing or a reference cannot be represented
* safely, the original document is returned instead of a partial slice.
*/
export function sliceDocumentForPage<T extends object>(
bundled: T,
operations: PageOperation[] = [],
webhooks: PageWebhook[] = []
): T {
if (operations.length === 0 && webhooks.length === 0) return bundled;
const document = bundled as T & SliceableDocument;
const paths: Record<string, unknown> = {};
for (const [path, methods] of groupByKey(operations, op => op.path)) {
const pathItem = document.paths?.[path];
if (!pathItem) return bundled; // Unexpected shape -- do not risk a partial document.
const kept = pickOperations(pathItem, methods);
if (!kept) return bundled;
paths[path] = kept;
}
const keptWebhooks: Record<string, unknown> = {};
for (const [name, methods] of groupByKey(webhooks, hook => hook.name)) {
const webhookItem = document.webhooks?.[name];
if (!webhookItem) return bundled;
const kept = pickOperations(webhookItem, methods);
if (!kept) return bundled;
keptWebhooks[name] = kept;
}
const refs = collectComponentRefs(bundled, { paths, webhooks: keptWebhooks }, new Set());
if (!refs) return bundled; // Non-component internal reference -- keep everything.
const components: Record<string, Record<string, unknown>> = {};
for (const ref of refs) {
const [, , group, ...rest] = ref.split('/');
if (!group || rest.length !== 1) return bundled;
const name = decodePointerSegment(rest[0]);
const value = resolvePointer(bundled, ref);
if (value === undefined) return bundled; // Dangling pointer -- keep everything.
(components[group] ??= {})[name] = value;
}
// Security requirements name schemes directly rather than via `$ref`, so the
// reachability walk above never sees them.
if (document.components?.securitySchemes) {
components.securitySchemes = document.components.securitySchemes;
}
const sliced: Record<string, unknown> = { ...bundled, paths };
if (Object.keys(components).length > 0) sliced.components = components;
else delete sliced.components;
if (document.webhooks) sliced.webhooks = keptWebhooks;
return sliced as T;
}
/**
* Applies {@link sliceDocumentForPage} to fumadocs' page props, leaving any
* other props shape (for example the preloaded variant) untouched.
*/
export function sliceApiPageProps<T extends OpenAPIPageProps>(props: T): T {
if (!('payload' in props)) return props;
return {
...props,
payload: {
...props.payload,
bundled: sliceDocumentForPage(props.payload.bundled, props.operations, props.webhooks),
},
} as T;
}