feat(openapi): anchor links for request/response/parameter properties (#3327)

* feat(openapi): anchor links on schema properties

Each property rendered inline in a request body, response body, or parameter
now gets a stable id and a hover-revealed link icon, enabling deep links to
specific fields from guides. Ids follow:
  - body-<prop> for request bodies
  - response-<status>-<prop> for responses
  - parameters-<location>-<name> for parameters

A new idPrefix option on SchemaUI opts other Schema renderings into the same
behavior. See fuma-nama/fumadocs#3322.

* feat(openapi): deep-link nested schema properties

Extend the property anchor scheme to support hashes that point inside a
popover or below an array's "Show array" collapsible. Schema-internal
segments are joined with `.` so `#body.user.address.street` resolves
unambiguously regardless of dashes in property names.

On hash match, the inline anchor scrolls into view, the matching popover
auto-opens with its breadcrumb pre-navigated, the array collapsible
expands, and oneOf/anyOf tabs switch to the variant containing the
targeted field. The deep target is consumed after the first auto-open,
so subsequent user clicks on the same trigger get the base view; a
different deep hash re-arms it.

The hover anchor is now a copy-link button (matches fumadocs heading
anchors) that writes the full URL to the clipboard and updates the
address bar via history.replaceState (no hashchange, no popover reset).

* address PR review feedback

- Clarified the changeset: split anchor ID rules by context (request/response property paths vs flat parameter anchors) so dots aren't implied to apply to parameters.
- Namespaced anchors by media type when an operation has multiple request-body or response content types: prefix becomes `<media-slug>-body` / `response-<status>-<media-slug>`, and the content-type SelectTabs auto-switches to the targeted tab when a deep link arrives. Single-content-type operations keep the simple `body` / `response-<status>` form.
- Made findUnionPropMatch validate the *full* remaining path (via new pathResolves helper) so two variants that share the first prop name no longer race; the wrong tab can't be selected for a deep hash anymore.
- Reset SchemaUIPopover breadcrumb state when initialPath changes so a fresh deep hash takes effect on an already-open popover.
- Replaced the lossy slugifyPropertyName with encodeURIComponent (plus % encoding for `.`) so property anchors stay collision-safe across case-only differences, punctuation-only differences, and non-ASCII keys, while `.` remains exclusively a path separator.

* address PR review feedback

- Fix potential hydration mismatch in useTabValueFromHash by initializing state with the fallback value and resolving from the URL hash inside useEffect; the deep-linked tab now corrects after hydration on the client.

* fix(openapi): move useTabValueFromHash into a client component

The previous hydration-mismatch fix put `useEffect`/`useState` directly in
`operation/index.tsx`, which is a Server Component — Next refused to compile
because RSC modules can't import client-only React APIs. Extract the
hash-aware tab wrapper into `operation/hash-tabs.tsx` (with `"use client"`)
and switch both body and response sites to `HashAwareSelectTabs`. The parent
tree stays server-side; only the tab wrapper hydrates on the client.

* address PR review feedback

- Wrap decodeURIComponent in try/catch in both deep-link hash parsers so a malformed percent-encoded fragment (e.g. `#body%`) is treated as "no target" instead of throwing URIError and breaking tab / popover resolution.

* address PR review feedback

- Match the URL hash against DOM ids using the raw fragment first, with the fully-decoded form as a fallback, in both DeepLinkProvider and hash-tabs. Property anchors are generated via slugifyPropertyName (which percent-encodes reserved chars like `.`), so DOM ids contain the encoded form; decoding the hash before lookup caused links like `#body.foo%2Ebar` to miss the matching element.

* redesign

* fix types

---------

Co-authored-by: Fuma Nama <[email protected]>
This commit is contained in:
Avner Matan
2026-06-02 03:43:01 +08:00
committed by GitHub
co-authored by Fuma Nama
parent 34b75a0cfb
commit 23055816e6
10 changed files with 601 additions and 424 deletions
@@ -0,0 +1,23 @@
---
'fumadocs-openapi': minor
---
Add anchor links to every property in OpenAPI request bodies, response bodies, and parameters.
Each property rendered inline gets a stable `id` and a hover-revealed copy-link button (matches the existing fumadocs heading anchor pattern). Clicking the button copies the full URL to the clipboard so writers can deep-link to a specific property from guides and instructions.
Two ID styles depending on context:
- **Request/response property paths** — `-` separates operation-level segments, `.` separates schema-internal path segments:
- Request body: `#body.<prop>` (e.g. `#body.user`)
- Response per status: `#response-<status>.<prop>` (e.g. `#response-200.data`)
- Deeper paths: `#body.user.address.street`
- **Parameter anchors** — a flat, hyphen-separated id with no schema-internal path:
- `#parameters-<location>-<name>` (e.g. `#parameters-query-limit`)
- If a parameter is itself an object, its sub-properties still join with `.` — e.g. `#parameters-query-filter.foo`.
When an operation has multiple body or response content types, the media type is folded into the prefix so anchors stay unique (e.g. `#application-json-body.user` vs `#application-xml-body.user`, `#response-200-application-json.data`). The content-type selector auto-switches to the targeted tab when a deep link arrives. Single-content-type operations keep the simple `body` / `response-<status>` form.
Property names are encoded into anchor segments via `encodeURIComponent` (with `.` additionally percent-encoded) so distinct names like `ETag` vs `etag`, `foo bar` vs `foo-bar`, or non-ASCII keys don't collapse to the same anchor. When the hash lives below a top-level property, the schema-popover auto-opens with its breadcrumb already navigated, the array `Show array` collapsible auto-expands, and `oneOf` / `anyOf` tabs switch to the variant containing the targeted field. Once consumed, subsequent clicks on the same trigger open the base view — a different deep hash re-arms the auto-open.
The new `idPrefix` option on `SchemaUI` opts arbitrary `Schema` renderings into the same behavior.
+12 -8
View File
@@ -76,6 +76,8 @@ security:
- basicAuth: []
- apiKeyQuery: []
- apiKeyHeader: []
- apiKeyHeader: []
apiKeyQuery: []
- apiKeyCookie: []
- oAuth2: []
- openIdConnect: []
@@ -103,8 +105,7 @@ paths:
description: It's easy to say you know them all, but do you really? Retrieve all
the planets and check whether you missed one.
operationId: getAllData
security:
- {}
security: []
parameters:
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/offset'
@@ -171,6 +172,7 @@ paths:
planetCreated:
'{$request.body#/successCallbackUrl}':
post:
security: []
requestBody:
description: Information about the newly created planet
content:
@@ -186,6 +188,7 @@ paths:
planetCreationFailed:
'{$request.body#/failureCallbackUrl}':
post:
security: []
requestBody:
description: Information about which fields failed to validate
content:
@@ -198,6 +201,7 @@ paths:
planetExploded:
'{$request.body#/successCallbackUrl}':
post:
security: []
requestBody:
description: Information about the newly exploded planet
content:
@@ -259,8 +263,7 @@ paths:
description: You'll better learn a little bit more about the planets. It might
come in handy once space travel is available for everyone.
operationId: getPlanet
security:
- {}
security: []
parameters:
- $ref: '#/components/parameters/planetId'
responses:
@@ -453,8 +456,7 @@ paths:
summary: Create a user
description: Time to create a user account, eh?
operationId: createUser
security:
- {}
security: []
requestBody:
description: User to create
content:
@@ -549,8 +551,7 @@ paths:
description: Yeah, this is the boring security stuff. Just get your super secret
token and move on.
operationId: getToken
security:
- {}
security: []
requestBody:
description: Body for credentials to authenticate a user
content:
@@ -615,6 +616,8 @@ paths:
- bearerAuth: []
- apiKeyHeader: []
- apiKeyQuery: []
- apiKeyHeader: []
apiKeyQuery: []
responses:
'200':
description: Authenticated user information retrieved successfully
@@ -657,6 +660,7 @@ webhooks:
post:
tags:
- Planets
security: []
requestBody:
description: Information about a new planet
content:
+12 -9
View File
@@ -76,6 +76,8 @@ security:
- basicAuth: []
- apiKeyQuery: []
- apiKeyHeader: []
- apiKeyHeader: []
apiKeyQuery: []
- apiKeyCookie: []
- oAuth2: []
- openIdConnect: []
@@ -103,8 +105,7 @@ paths:
description: It's easy to say you know them all, but do you really? Retrieve all
the planets and check whether you missed one.
operationId: getAllData
security:
- {}
security: []
parameters:
- $ref: '#/components/parameters/limit'
- $ref: '#/components/parameters/offset'
@@ -171,6 +172,7 @@ paths:
planetCreated:
'{$request.body#/successCallbackUrl}':
post:
security: []
requestBody:
description: Information about the newly created planet
content:
@@ -186,6 +188,7 @@ paths:
planetCreationFailed:
'{$request.body#/failureCallbackUrl}':
post:
security: []
requestBody:
description: Information about which fields failed to validate
content:
@@ -198,6 +201,7 @@ paths:
planetExploded:
'{$request.body#/successCallbackUrl}':
post:
security: []
requestBody:
description: Information about the newly exploded planet
content:
@@ -259,8 +263,7 @@ paths:
description: You'll better learn a little bit more about the planets. It might
come in handy once space travel is available for everyone.
operationId: getPlanet
security:
- {}
security: []
parameters:
- $ref: '#/components/parameters/planetId'
responses:
@@ -351,7 +354,6 @@ paths:
- Planets
summary: Delete a planet
operationId: deletePlanet
deprecated: true
description: This endpoint was used to delete planets. Unfortunately, that
caused a lot of trouble for planets with life. So, this endpoint is now
deprecated and should not be used anymore.
@@ -454,8 +456,7 @@ paths:
summary: Create a user
description: Time to create a user account, eh?
operationId: createUser
security:
- {}
security: []
requestBody:
description: User to create
content:
@@ -550,8 +551,7 @@ paths:
description: Yeah, this is the boring security stuff. Just get your super secret
token and move on.
operationId: getToken
security:
- {}
security: []
requestBody:
description: Body for credentials to authenticate a user
content:
@@ -616,6 +616,8 @@ paths:
- bearerAuth: []
- apiKeyHeader: []
- apiKeyQuery: []
- apiKeyHeader: []
apiKeyQuery: []
responses:
'200':
description: Authenticated user information retrieved successfully
@@ -658,6 +660,7 @@ webhooks:
post:
tags:
- Planets
security: []
requestBody:
description: Information about a new planet
content:
@@ -2,7 +2,17 @@
import { cn } from '@/utils/cn';
import { SelectTrigger, Select, SelectValue, SelectContent, SelectItem } from './select';
import { type ReactNode, useState, useMemo, type ComponentProps, createContext, use } from 'react';
import {
type ReactNode,
useState,
useMemo,
type ComponentProps,
createContext,
use,
useEffect,
} from 'react';
import { anchorIdStartsWith } from '@/utils/auto-anchor';
import { AnchorSection, useAnchorId } from '@/utils/auto-anchor.client';
const Context = createContext<{
value: string | null;
@@ -23,14 +33,26 @@ export function SelectTabs({
export function SelectTab({
value,
anchorSegments: segments,
...props
}: ComponentProps<'div'> & {
value: string;
/** define the tab as an anchor section */
anchorSegments?: string[];
}) {
const ctx = use(Context);
if (value !== ctx?.value) return;
const { value: currentValue, setValue } = use(Context)!;
const id = useAnchorId(...(segments ?? []));
return <div {...props}>{props.children}</div>;
useEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash || !segments) return;
if (anchorIdStartsWith(hash, id)) setValue(value);
}, [id, segments, value, setValue]);
if (value !== currentValue) return;
const content = <div {...props} />;
return segments ? <AnchorSection segments={segments}>{content}</AnchorSection> : content;
}
export function SelectTabTrigger({
+50 -42
View File
@@ -27,6 +27,7 @@ import { cn } from '@/utils/cn';
import { getExampleRequests } from './get-example-requests';
import { SelectTabs, SelectTabTrigger, SelectTab } from '../components/select-tab';
import { Callout } from 'fumadocs-ui/components/callout';
import { AnchorSection } from '@/utils/auto-anchor.client';
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
@@ -82,11 +83,10 @@ export function Operation({
}
const contentTypes = body?.content ? Object.entries(body.content) : null;
if (body && contentTypes && contentTypes.length > 0) {
const items = contentTypes.map(([key]) => ({
label: <code className="text-xs">{key}</code>,
value: key,
if (contentTypes && contentTypes.length > 0) {
const items = contentTypes.map(([type]) => ({
label: <code className="text-xs">{type}</code>,
value: type,
}));
bodyNode = (
@@ -102,14 +102,14 @@ export function Operation({
<p className="text-fd-muted-foreground not-prose">{items[0].label}</p>
)}
</div>
{body.description && ctx.renderMarkdown(body.description)}
{body!.description && ctx.renderMarkdown(body!.description)}
{contentTypes.map(([type, content]) => {
if (!isMediaTypeSupported(type, ctx.mediaAdapters)) {
throw new Error(`Media type ${type} is not supported (in ${path})`);
}
return (
<SelectTab key={type} value={type}>
<SelectTab key={type} anchorSegments={['request-body', type]} value={type}>
<RequestBodyContentItem content={content} method={method} ctx={ctx} />
</SelectTab>
);
@@ -145,33 +145,35 @@ export function Operation({
{ctx.renderHeading(headingLevel, <I18nLabel label={`${type}Parameters`} />, {
id: `parameters-${type}`,
})}
<div className="flex flex-col">
{params.map(
(param) =>
param.schema != null && (
<Schema
key={param.name}
client={{
name: param.name!,
required: param.required,
}}
root={
typeof param.schema === 'object'
? {
...param.schema,
description: param.description ?? param.schema?.description,
deprecated:
(param.deprecated ?? false) || (param.schema?.deprecated ?? false),
}
: param.schema
}
readOnly={method.method === 'get'}
writeOnly={method.method !== 'get'}
ctx={ctx}
/>
),
)}
</div>
<AnchorSection segments={['parameters', type]}>
<div className="flex flex-col">
{params.map(
(param) =>
param.schema != null && (
<Schema
key={param.name}
client={{
name: param.name!,
required: param.required,
}}
root={
typeof param.schema === 'object'
? {
...param.schema,
description: param.description ?? param.schema?.description,
deprecated:
(param.deprecated ?? false) || (param.schema?.deprecated ?? false),
}
: param.schema
}
readOnly={method.method === 'get'}
writeOnly={method.method !== 'get'}
ctx={ctx}
/>
),
)}
</div>
</AnchorSection>
</Fragment>
);
});
@@ -248,7 +250,7 @@ export function Operation({
)}
</div>
{callbacks.map(([name, callback]) => (
<SelectTab key={name} value={name}>
<SelectTab key={name} value={name} anchorSegments={['webhook', name]}>
<WebhookCallback callback={callback} ctx={ctx} headingLevel={headingLevel} />
</SelectTab>
))}
@@ -409,15 +411,16 @@ function ResponseAccordion({
}) {
const response = operation.responses![status];
const contentTypes = response.content ? Object.entries(response.content) : [];
const items = contentTypes.map(([key]) => ({
label: <code className="text-xs">{key}</code>,
value: key,
}));
let wrapper = (children: ReactNode) => children;
let selectorNode: ReactNode = null;
if (contentTypes.length > 0) {
const items = contentTypes.map(([key]) => ({
label: <code className="text-xs">{key}</code>,
value: key,
}));
selectorNode =
items.length === 1 ? (
<p className="text-fd-muted-foreground not-prose">{items[0].label}</p>
@@ -438,7 +441,12 @@ function ResponseAccordion({
<div className="prose-no-margin mb-2">{ctx.renderMarkdown(response.description)}</div>
)}
{contentTypes.map(([type, item]) => (
<SelectTab key={type} value={type} className="mb-2">
<SelectTab
key={type}
value={type}
className="mb-2"
anchorSegments={['response', status, type]}
>
<RepsonseAccordionItem
type={type}
status={status}
@@ -486,7 +494,7 @@ function RepsonseAccordionItem({
<>
{ts && <CopyTypeScriptPanel name="response body" code={ts} />}
{schema && (
<div className="border px-3 py-2 rounded-lg">
<div className="border p-3 pb-2 rounded-lg">
<Schema
client={{
name: 'response',
+424 -360
View File
@@ -10,12 +10,10 @@ import {
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
import { useTranslations } from '@/ui/client/i18n';
import type {
InfoTag,
@@ -23,22 +21,44 @@ import type {
SchemaDataObjectProperty,
SchemaUIGeneratedData,
} from '@/ui/schema';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ChevronDown, FilterIcon } from 'lucide-react';
import { CheckIcon, ChevronDown, FilterIcon, LinkIcon } from 'lucide-react';
import { Badge } from '@/ui/components/method-label';
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
import { cn } from '@/utils/cn';
import { cva } from 'class-variance-authority';
import { useAnchorId } from '@/utils/auto-anchor.client';
import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button';
import { mergeRefs } from '@/utils/merge-refs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/select';
type DataContextType = SchemaUIGeneratedData;
interface PathItemType {
name: string;
$ref: string;
scrollTop?: number;
/** property name of highlighted field, only applicable for objects */
highlighted?: string;
tabValues?: string[];
closed?: true;
}
interface PopoverContextType {
renderTrigger: (props: { pathName: ReactNode; $ref: string; children: ReactNode }) => ReactNode;
interface StateContextType {
rootId: string;
/** the first tiem will always be the root item */
path: PathItemType[];
setPath: (path: PathItemType[]) => void;
generated: SchemaUIGeneratedData;
renderTypeInfoTrigger: (props: {
pathName: string;
$ref: string;
children: ReactNode;
}) => ReactNode;
}
const typeVariants = cva('text-sm text-start text-fd-muted-foreground font-mono', {
@@ -50,18 +70,10 @@ const typeVariants = cva('text-sm text-start text-fd-muted-foreground font-mono'
},
});
const PopoverContext = createContext<PopoverContextType>({
renderTrigger: (props) => <RootPopoverTrigger {...props} />,
});
const Context = createContext<StateContextType | null>(null);
const DataContext = createContext<DataContextType | null>(null);
function useData() {
return use(DataContext)!;
}
function usePopover() {
return use(PopoverContext);
function useStates() {
return use(Context)!;
}
export interface SchemaUIProps {
@@ -73,133 +85,297 @@ export interface SchemaUIProps {
}
export function SchemaUI({ name, required = false, as = 'property', generated }: SchemaUIProps) {
const rootId = useAnchorId(name);
const [path, _setPath] = useState<PathItemType[]>(() => [{ $ref: generated.$root, name }]);
const ref = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const setPath = useCallback((v: PathItemType[]) => {
for (const item of v) delete item.highlighted;
_setPath((current) => {
if (popoverRef.current && current.length > 0) {
current[current.length - 1].scrollTop = popoverRef.current.scrollTop;
}
return v;
});
}, []);
// ensure popover scroll top & height is stable
useEffect(() => {
const element = popoverRef.current;
if (!element) return;
element.scrollTop = path.at(-1)!.scrollTop ?? 0;
const current = parseFloat(element.style.getPropertyValue('--min-height') || '0');
element.style.setProperty('--min-height', Math.max(element.clientHeight + 2, current) + 'px');
}, [path]);
useEffect(() => {
const url = new URL(window.location.href);
const param = url.searchParams.get('path');
if (url.hash !== `#${rootId}` || !param) return;
const decoded = decodePath(param, url.searchParams.get('s-highlight'));
if (!decoded || decoded.length === 0 || decoded.some((item) => !generated.refs[item.$ref]))
return;
_setPath(decoded);
if (!decoded.at(-1)!.highlighted) {
ref.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [rootId]);
return (
<DataContext value={generated}>
<SchemaUIProperty
name={name}
$type={generated.$root}
overrides={{
required,
}}
variant={
as === 'property' || generated.refs[generated.$root].type === 'primitive'
? 'default'
: 'expand'
}
/>
</DataContext>
<Context
value={useMemo(
() => ({
rootId,
path,
generated,
setPath,
renderTypeInfoTrigger: ({ $ref, children, pathName }) => (
<Popover
open={
path[1] &&
path[1].$ref === $ref &&
path[1].name === pathName &&
!path.at(-1)!.closed
}
onOpenChange={(v) => {
if (v) {
setPath([path[0], { name: pathName, $ref }]);
} else {
const next = [...path];
next.at(-1)!.closed = true;
setPath(next);
}
}}
>
<PopoverTrigger className={cn(typeVariants({ variant: 'trigger' }))}>
{children}
</PopoverTrigger>
<PopoverContent
ref={popoverRef}
className="w-[600px] min-h-(--min-height,0) max-h-[460px] px-2 pt-0"
onOpenAutoFocus={(e) => {
const element = e.target as HTMLElement;
const input = element.querySelector('input[data-object-search-input]');
if (!(input instanceof HTMLInputElement)) return;
input.focus({ preventScroll: true });
e.preventDefault();
}}
>
<SchemaUIPopover />
</PopoverContent>
</Popover>
),
}),
[generated, path, rootId, setPath],
)}
>
{as === 'property' || generated.refs[generated.$root].type === 'primitive' ? (
<ObjectProperty
ref={ref}
id={rootId}
name={name}
$type={generated.$root}
required={required}
/>
) : (
<div id={rootId} ref={ref}>
<PathItemBody pathIndex={0} />
</div>
)}
</Context>
);
}
function SchemaUIProperty({
name,
$type,
variant = 'default',
overrides,
objectSearchOverrides,
}: {
name: ReactNode;
$type: string;
variant?: 'default' | 'expand';
overrides?: Partial<PropertyProps>;
objectSearchOverrides?: Partial<ObjectSearchProps>;
}) {
const { refs } = useData();
const schema = refs[$type];
const renderRef = useRenderRef();
let type: ReactNode = schema.aliasName;
if ((schema.type === 'or' || schema.type === 'and') && schema.items.length > 0) {
if (variant === 'expand')
return (
<Tabs defaultValue={schema.items[0].$type}>
<TabsList>
{schema.items.map((item) => (
<TabsTrigger key={item.$type} value={item.$type}>
{item.name}
</TabsTrigger>
))}
</TabsList>
{schema.items.map((item) => (
<TabsContent key={item.$type} value={item.$type} className="pt-2 pb-0">
<SchemaUIProperty {...item} variant="expand" />
</TabsContent>
))}
</Tabs>
);
type = renderRef({
pathName: name,
$ref: $type,
text: schema.aliasName,
});
} else if (schema.type === 'object' && schema.props.length > 0) {
if (variant === 'expand')
return <ObjectSearch properties={schema.props} {...objectSearchOverrides} />;
type = renderRef({
pathName: name,
$ref: $type,
text: schema.aliasName,
});
} else if (schema.type === 'array') {
if (variant === 'expand') return <ArrayItemCollapsible schema={schema} />;
type = renderRef({
pathName: name,
$ref: $type,
text: schema.aliasName,
});
}
const child = (
<>
function SchemaDescription({ schema, ...props }: ComponentProps<'div'> & { schema: SchemaData }) {
return (
<div {...props} className={cn('prose-no-margin py-2.5 empty:hidden', props.className)}>
{schema.description}
{schema.infoTags && schema.infoTags.length > 0 && (
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
<div className="flex flex-row gap-2 flex-wrap mt-2 not-prose empty:hidden">
{schema.infoTags.map((tag, i) => (
<InfoTag key={i} tag={tag} />
))}
</div>
)}
</>
);
if (variant === 'expand') return child;
return (
<Property name={name} type={type} deprecated={schema.deprecated} {...overrides}>
{child}
</Property>
</div>
);
}
function ArrayItemCollapsible({ schema }: { schema: Extract<SchemaData, { type: 'array' }> }) {
const [open, setOpen] = useState(false);
function ObjectProperty({
name,
$type,
required,
...props
}: ComponentProps<'div'> & {
name: string;
$type: string;
required?: boolean;
}) {
const t = useTranslations();
const {
path,
generated: { refs },
rootId,
} = useStates();
const schema = refs[$type];
const highlighted = path.at(-1)!.highlighted === name;
const [isChecked, onClick] = useCopyButton(() => {
const url = new URL(window.location.href);
url.hash = `#${rootId}`;
url.searchParams.set('s-highlight', name);
url.searchParams.set('path', encodePath(path));
return navigator.clipboard.writeText(url.href);
});
return (
<Collapsible className="my-2" open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'secondary', size: 'sm' }),
'group px-3 py-2 data-[state=open]:rounded-b-none',
<div
{...props}
ref={mergeRefs(
props.ref,
useCallback(
(element: HTMLDivElement | null) => {
if (element && highlighted) element.scrollIntoView();
},
[highlighted],
),
)}
className={cn('text-sm border-t py-4 first:border-t-0', props.className)}
>
<div className="flex flex-wrap items-center gap-3 not-prose">
<span className="font-medium font-mono">
<span
className={cn(
highlighted
? 'bg-fd-primary text-fd-primary-foreground rounded-sm'
: 'text-fd-primary',
schema.deprecated && 'line-through opacity-80',
)}
>
{name}
</span>
{required ? (
<span className="text-red-400">*</span>
) : (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
{schema.type === 'primitive' ? (
<span className={cn(typeVariants())}>{schema.aliasName}</span>
) : (
<TypeInfoTrigger pathName={name} $ref={$type}>
{schema.aliasName}
</TypeInfoTrigger>
)}
>
{open ? t.schemaHideArray : t.schemaShowArray}
<ChevronDown className="size-4 text-fd-muted-foreground group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mt-px bg-fd-card px-3 rounded-lg rounded-tl-none border shadow-sm">
<SchemaUIProperty name="" $type={schema.item.$type} variant="expand" />
</CollapsibleContent>
</Collapsible>
<div className="flex-1" />
{schema.deprecated && (
<Badge color="yellow" className="text-xs">
{t.deprecated}
</Badge>
)}
<button
className={cn(
buttonVariants({ size: 'icon-xs', variant: 'ghost' }),
'text-fd-muted-foreground',
)}
onClick={onClick}
>
{isChecked ? <CheckIcon /> : <LinkIcon />}
</button>
</div>
<SchemaDescription schema={schema} className="pb-0" />
</div>
);
}
function PathItemBody({
pathIndex,
asSchema,
tabDepth = 0,
objectSearchOverrides,
}: {
pathIndex: number;
asSchema?: SchemaData;
tabDepth?: number;
objectSearchOverrides?: Partial<ObjectSearchProps>;
}) {
const {
path,
setPath,
generated: { refs },
} = useStates();
const schema = asSchema ?? refs[path[pathIndex].$ref];
if ((schema.type === 'or' || schema.type === 'and') && schema.items.length > 0) {
const value = path[pathIndex].tabValues?.[tabDepth] ?? schema.items[0].$type;
const items = schema.items.map((item) => ({
label: <code className="text-xs font-medium">{item.name}</code>,
value: item.$type,
}));
return (
<Select
value={value}
onValueChange={(v) => {
const next = [...path];
(next[pathIndex].tabValues ??= []).splice(tabDepth, 1, v);
setPath(next);
}}
>
<div className="flex flex-row my-2 gap-2 items-center">
<SchemaDescription schema={schema} className="flex-1 py-0" />
<SelectTrigger className="not-prose w-fit min-w-0 mb-auto *:min-w-0">
<SelectValue>{value && items.find((item) => item.value === value)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{items.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</div>
<PathItemBody asSchema={refs[value]} pathIndex={pathIndex} tabDepth={tabDepth + 1} />
</Select>
);
}
if (schema.type === 'object' && schema.props.length > 0) {
return (
<ObjectSearch properties={schema.props} {...objectSearchOverrides}>
<SchemaDescription schema={schema} />
</ObjectSearch>
);
}
if (schema.type === 'array') {
return (
<>
<SchemaDescription schema={schema} />
<ObjectProperty name="[index: integer]" $type={schema.item.$type} />
</>
);
}
return <SchemaDescription schema={schema} />;
}
interface ObjectSearchProps {
variant?: 'ghost' | 'secondary';
properties: SchemaDataObjectProperty[];
container?: ComponentProps<'div'>;
open?: (item: SchemaDataObjectProperty) => void;
inputContainer?: ComponentProps<'div'>;
children?: ReactNode;
}
function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
function ObjectSearch({
variant = 'secondary',
properties,
inputContainer,
children,
}: ObjectSearchProps) {
const { path, setPath } = useStates();
const [search, setSearch] = useState('');
const deferredValue = useDeferredValue(search);
const firstItemRef = useRef<SchemaDataObjectProperty>(null);
@@ -214,27 +390,32 @@ function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
return (
<>
<div
{...container}
{...inputContainer}
className={cn(
'flex items-center border my-2 rounded-md bg-fd-secondary text-fd-secondary-foreground transition-colors shadow-sm focus-within:ring-2 focus-within:ring-fd-ring',
container?.className,
'flex items-center bg-fd-secondary text-fd-secondary-foreground transition-colors',
variant === 'ghost' && 'border-b focus-within:[&_svg]:text-fd-primary',
variant === 'secondary' &&
'border bg-fd-secondary rounded-md shadow-sm focus-within:ring-2 focus-within:ring-fd-ring',
inputContainer?.className,
)}
>
<FilterIcon className="text-fd-muted-foreground ms-2 size-3.5" />
<FilterIcon className="text-fd-muted-foreground ms-2 size-3.5 transition-colors" />
<input
value={search}
data-object-search-input=""
onChange={(e) => setSearch(e.target.value)}
placeholder={t.schemaFilterPropertiesPlaceholder}
className="text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
className="peer text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && open) {
if (firstItemRef.current) open(firstItemRef.current);
if (e.key === 'Enter') {
const item = firstItemRef.current;
if (item) setPath([...path, { name: item.name, $ref: item.$type }]);
e.preventDefault();
}
}}
/>
</div>
{children}
<Suspense>
<ObjectSearchContent
search={deferredValue}
@@ -263,23 +444,18 @@ function ObjectSearchContent({
: properties;
}, [properties, rawSearch]);
firstItemRef.current = filtered.length > 0 ? filtered[0] : null;
firstItemRef.current = filtered[0] ?? null;
if (filtered.length === 0)
return (
<p className="text-fd-muted-foreground text-sm px-2">
<p className="text-fd-muted-foreground text-sm">
{t.schemaFilterPropertiesEmpty}{' '}
<span className="text-fd-foreground font-medium">{`"${rawSearch}"`}</span>
</p>
);
return filtered.map((prop) => (
<SchemaUIProperty
key={prop.name}
name={prop.name}
$type={prop.$type}
overrides={{ required: prop.required }}
/>
<ObjectProperty key={prop.name} name={prop.name} $type={prop.$type} required={prop.required} />
));
}
@@ -318,249 +494,137 @@ function InfoTag({ tag }: { tag: InfoTag }) {
);
}
interface PathItemType {
name: ReactNode;
$ref?: string;
scrollTop?: number;
}
function SchemaUIPopover({
containerRef,
initialPath,
}: {
containerRef: RefObject<HTMLDivElement | null>;
initialPath: PathItemType[];
}) {
const [path, setPath] = useState(initialPath);
useLayoutEffect(() => {
const last = path[0];
const element = containerRef.current;
if (!element || !last || !element.parentElement) return;
// recover scroll
element.parentElement.scrollTop = last.scrollTop ?? 0;
return () => {
if (element.parentElement) last.scrollTop = element.parentElement.scrollTop;
};
}, [containerRef, path]);
const context: PopoverContextType = useMemo(
() => ({
renderTrigger: ({ $ref, pathName, children }) => (
<button
className={cn(typeVariants({ variant: 'trigger' }))}
onClick={() => setPath((path) => [...path, { name: pathName, $ref }])}
>
{children}
</button>
),
}),
[],
);
const currentRef = path.findLast((item) => item.$ref !== undefined);
function SchemaUIPopover() {
const states = useStates();
const { path, setPath } = states;
return (
<>
<div className="sticky top-0 -mx-2 flex flex-row flex-wrap items-center text-sm font-medium font-mono bg-fd-popover px-2 h-8 border-b">
{path.map((item, i) => {
const isDuplicated = path.some((other, j) => j < i && other.$ref === item.$ref);
const className = cn(
isDuplicated && 'text-orange-400',
item.$ref && 'hover:underline hover:text-fd-accent-foreground',
);
const node = item.$ref ? (
<button onClick={() => setPath((path) => path.slice(0, i + 1))} className={className}>
{item.name}
<Context
value={useMemo(
() => ({
...states,
renderTypeInfoTrigger: ({ $ref, pathName, children }) => (
<button
className={cn(typeVariants({ variant: 'trigger' }))}
onClick={() => setPath([...path, { name: pathName, $ref }])}
>
{children}
</button>
) : (
<span className={className}>{item.name}</span>
);
),
}),
[states],
)}
>
<div className="sticky top-0 -mx-2 flex flex-row overflow-x-auto overflow-y-hidden items-center text-sm font-medium font-mono bg-fd-popover px-2 h-10 border-b z-20">
{path.map((item, i) => {
// ignore root
if (i === 0) return;
const isDuplicated = path.some((other, j) => j !== i && other.$ref === item.$ref);
let text: string;
const indexItemMatch = /^\[(\w+): (\w+)]$/.exec(item.name);
if (indexItemMatch) {
text = `[${indexItemMatch[1]}]`;
} else if (i > 1) {
text = `.${item.name}`;
} else {
text = item.name;
}
return (
<Fragment key={i}>
{i > 0 && '.'}
{node}
</Fragment>
<button
key={i}
onClick={() => setPath(path.slice(0, i + 1))}
className={cn(
'hover:underline hover:text-fd-accent-foreground',
isDuplicated && 'text-orange-400',
)}
>
{text}
</button>
);
})}
</div>
<PopoverContext value={context}>
{currentRef?.$ref && (
<SchemaUIProperty
name=""
$type={currentRef.$ref}
variant="expand"
objectSearchOverrides={{
container: {
className: 'sticky top-10',
},
open(item) {
setPath((path) => [...path, { name: item.name, $ref: item.$type }]);
},
}}
/>
)}
</PopoverContext>
</>
<PathItemBody
pathIndex={path.length - 1}
objectSearchOverrides={{
variant: 'ghost',
inputContainer: {
className: 'sticky top-10 -mx-2',
},
}}
/>
</Context>
);
}
function useRenderRef() {
const { refs } = useData();
const { renderTrigger } = usePopover();
return function renderRef({
pathName,
$ref,
text,
}: {
pathName: ReactNode;
$ref: string;
text: ReactNode;
}) {
const schema = refs[$ref];
if (!isExpandable(schema)) {
return <span className={cn(typeVariants())}>{text}</span>;
}
if (schema.type === 'and' || schema.type === 'or') {
const sep = schema.type === 'and' ? '&' : '|';
return (
<span className={cn(typeVariants(), 'flex flex-row gap-2 items-center flex-wrap')}>
{schema.items.map((item, i) => (
<Fragment key={item.$type}>
{i > 0 && <span>{sep}</span>}
{renderRef({ pathName, text: item.name, $ref: item.$type })}
</Fragment>
))}
</span>
);
}
if (schema.type === 'array') {
return (
<span className={cn(typeVariants(), 'flex flex-row items-center flex-wrap')}>
{'array<'}
{renderRef({
pathName: <>{pathName}[]</>,
text: refs[schema.item.$type].aliasName,
$ref: schema.item.$type,
})}
{'>'}
</span>
);
}
return renderTrigger({ $ref, pathName, children: text });
};
}
function RootPopoverTrigger({
$ref,
function TypeInfoTrigger({
pathName,
$ref,
children,
}: {
pathName: ReactNode;
pathName: string;
$ref: string;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const refCallback = useCallback((element: HTMLDivElement | null) => {
ref.current = element;
if (!element || element.style.getPropertyValue('--initial-height')) return;
const {
generated: { refs },
renderTypeInfoTrigger,
} = useStates();
const schema = refs[$ref];
element.style.setProperty('--initial-height', `${element.clientHeight + 2}px`);
}, []);
if (
schema.type === 'primitive' &&
!schema.description &&
(!schema.infoTags || schema.infoTags.length === 0)
) {
return <span className={cn(typeVariants())}>{children}</span>;
}
return (
<Popover>
<PopoverTrigger className={cn(typeVariants({ variant: 'trigger' }))}>
{children}
</PopoverTrigger>
<PopoverContent
ref={refCallback}
onOpenAutoFocus={(e) => {
if (!ref.current) return;
const input = ref.current.querySelector('input[data-object-search-input]');
if (!(input instanceof HTMLInputElement)) return;
input.focus({ preventScroll: true });
e.preventDefault();
}}
className="w-[600px] min-h-(--initial-height,0) max-h-[460px] px-2 py-0"
>
<SchemaUIPopover
containerRef={ref}
initialPath={[
{
name: pathName,
$ref: $ref,
},
]}
/>
</PopoverContent>
</Popover>
);
if (schema.type === 'and' || schema.type === 'or') {
const sep = schema.type === 'and' ? '&' : '|';
return (
<span className={cn(typeVariants(), 'flex flex-row gap-2 items-center flex-wrap')}>
{schema.items.map((item, i) => (
<Fragment key={item.$type}>
{i > 0 && <span>{sep}</span>}
<TypeInfoTrigger pathName={pathName} $ref={item.$type}>
{item.name}
</TypeInfoTrigger>
</Fragment>
))}
</span>
);
}
if (schema.type === 'array') {
return (
<span className={cn(typeVariants(), 'flex flex-row items-center flex-wrap')}>
{'array<'}
<TypeInfoTrigger pathName={`${pathName}[]`} $ref={schema.item.$type}>
{refs[schema.item.$type].aliasName}
</TypeInfoTrigger>
{'>'}
</span>
);
}
return renderTypeInfoTrigger({ $ref, pathName, children });
}
interface PropertyProps {
name: ReactNode;
type: ReactNode;
required?: boolean;
deprecated?: boolean;
nested?: boolean;
children?: ReactNode;
className?: string;
function encodePath(path: PathItemType[]): string {
return path
.map((item) => [item.name, item.$ref, ...(item.tabValues ?? [])].join('\0').replaceAll('|', ''))
.join('|');
}
function Property({
name,
type,
required,
deprecated,
nested = false,
className,
...props
}: PropertyProps) {
const t = useTranslations();
return (
<div
className={cn(
'text-sm border-t',
nested
? 'p-3 border-x bg-fd-card last:rounded-b-xl first:rounded-tr-xl last:border-b'
: 'py-4 first:border-t-0',
className,
)}
>
<div className="flex flex-wrap items-center gap-3 not-prose">
<span className="font-medium font-mono text-fd-primary">
{name}
{required ? (
<span className="text-red-400">*</span>
) : (
<span className="text-fd-muted-foreground">?</span>
)}
</span>
{typeof type === 'string' ? (
<span className="text-sm font-mono text-fd-muted-foreground">{type}</span>
) : (
type
)}
{deprecated && (
<Badge color="yellow" className="ms-auto text-xs">
{t.deprecated}
</Badge>
)}
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">{props.children}</div>
</div>
);
}
function decodePath(path: string, highlighted: string | null): PathItemType[] | null {
const out: PathItemType[] = [];
for (const part of path.split('|')) {
const [name, $ref, ...tabValues] = part.split('\0');
out.push({ name, $ref, tabValues });
}
function isExpandable(data: SchemaData): boolean {
if (data.type !== 'primitive') return true;
return Boolean(data.description || (data.infoTags && data.infoTags.length > 0));
if (highlighted && out.length > 0) out[out.length - 1].highlighted = highlighted;
return out;
}
@@ -0,0 +1,20 @@
'use client';
import { createContext, type ReactNode, use, useMemo } from 'react';
import { anchorSegments } from './auto-anchor';
const AnchorContext = createContext<string[]>([]);
/** Append segment to anchor IDs */
export function AnchorSection({ segments, children }: { segments: string[]; children: ReactNode }) {
const v = use(AnchorContext);
return (
<AnchorContext value={useMemo(() => [...v, ...segments], [v, segments])}>
{children}
</AnchorContext>
);
}
export function useAnchorId(...segments: string[]) {
return anchorSegments(...use(AnchorContext), ...segments);
}
+21
View File
@@ -0,0 +1,21 @@
import { slug } from 'github-slugger';
/** slugify segments, append `\0` to keep segment as-is */
export function anchorSegments(...segs: string[]): string {
const mapped: string[] = [];
for (const seg of segs) {
if (seg.startsWith('\0')) {
mapped.push(seg.slice(1));
} else if (seg.length > 0) {
mapped.push(slug(seg));
}
}
return mapped.join('.');
}
/**
* Check if `id1` starts with `id2`
*/
export function anchorIdStartsWith(id1: string, id2: string): boolean {
return id1 === id2 || id1.startsWith(`${id2}.`);
}
+13
View File
@@ -0,0 +1,13 @@
import type * as React from 'react';
export function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null) {
ref.current = value;
}
});
};
}
@@ -1,5 +1,4 @@
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { OpenAPIServer } from '@/server';
import type { ApiPageProps, OperationItem, WebhookItem } from '@/ui/api-page';
import type { OperationObject, PathItemObject, TagObject } from '@/types';
import { getTagDisplayName, methodKeys, type NoReference } from '@/utils/schema';