feat(openapi): better Webhook UI

This commit is contained in:
Fuma Nama
2026-06-02 17:48:58 +08:00
parent 23055816e6
commit c5d16192ab
14 changed files with 285 additions and 311 deletions
+1 -19
View File
@@ -2,22 +2,4 @@
'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.
Support anchor link for schema UI properties
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': patch
---
Deprecate `renderHeading` option
-5
View File
@@ -48,11 +48,6 @@ export interface RenderContext
mediaAdapters: Record<string, MediaAdapter>;
renderHeading: (
depth: number,
text: string | ReactNode,
props?: HTMLAttributes<HTMLHeadingElement> & { id?: string },
) => ReactNode;
renderCodeBlock: (lang: string, code: string) => ReactNode;
}
+1 -14
View File
@@ -208,6 +208,7 @@ export interface CreateAPIPageOptions {
render?: (props: APIPlaygroundProps) => ReactNode;
};
/** @deprecated no longer used */
renderHeading?: (props: HTMLAttributes<HTMLHeadingElement>, depth: number) => ReactNode;
renderCodeBlock?: (props: { lang: string; code: string }) => ReactNode;
@@ -300,20 +301,6 @@ export function createAPIPage(
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
if (options.renderHeading) {
return options.renderHeading({ id, children: text, ...props }, depth);
}
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
},
generateTypeScriptDefinitions:
options.generateTypeScriptDefinitions ??
((schema, ctx) => {
@@ -2,18 +2,60 @@
import * as Primitive from '@radix-ui/react-accordion';
import { ChevronRight } from 'lucide-react';
import type { ComponentProps } from 'react';
import { createContext, use, useEffect, useMemo, useState, type ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { anchorIdStartsWith } from '@/utils/auto-anchor';
import { AnchorSection, useAnchorId } from '@/utils/auto-anchor.client';
export function Accordions(props: ComponentProps<typeof Primitive.Root>) {
return <Primitive.Root {...props} className={cn('divide-y divide-fd-border', props.className)} />;
const Context = createContext<{
type: 'single' | 'multiple';
setValue: (v: string | string[]) => void;
} | null>(null);
export function Accordions({
type,
defaultValue,
...props
}: Omit<ComponentProps<typeof Primitive.Root>, 'value' | 'onValueChange'>) {
const [value, setValue] = useState<string | string[]>(() =>
type === 'multiple' ? (defaultValue ?? []) : (defaultValue ?? ''),
);
return (
<Context value={useMemo(() => ({ type, setValue }), [type])}>
<Primitive.Root type={type} value={value as never} onValueChange={setValue} {...props} />
</Context>
);
}
export function AccordionItem(props: ComponentProps<typeof Primitive.Item>) {
return (
<Primitive.Item {...props} className={cn('scroll-m-20', props.className)}>
{props.children}
</Primitive.Item>
export function AccordionItem({
value,
className,
anchorSegments,
...props
}: ComponentProps<typeof Primitive.Item> & {
/** define the accordion as an anchor section */
anchorSegments?: string[];
}) {
const ctx = use(Context)!;
const id = useAnchorId(anchorSegments ?? false);
useEffect(() => {
if (id && anchorIdStartsWith(window.location.hash.slice(1), id))
ctx.setValue(ctx.type === 'single' ? value : [value]);
}, [value, id, ctx]);
const content = (
<Primitive.Item
value={value}
className={cn('scroll-m-20 border-b last:border-b-0', className)}
{...props}
/>
);
return anchorSegments ? (
<AnchorSection segments={anchorSegments}>{content}</AnchorSection>
) : (
content
);
}
@@ -25,9 +67,7 @@ export function AccordionContent(props: ComponentProps<typeof Primitive.Content>
'overflow-hidden data-[state=closed]:animate-fd-accordion-up data-[state=open]:animate-fd-accordion-down',
props.className,
)}
>
{props.children}
</Primitive.Content>
/>
);
}
+67 -71
View File
@@ -1,39 +1,43 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { useTranslations } from '@/ui/client/i18n';
const Dialog = DialogPrimitive.Root;
export const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
export const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
export const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
export const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export function DialogOverlay({
className,
ref,
...props
}: ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/30 backdrop-blur-sm data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out',
className,
)}
{...props}
/>
);
}
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
export function DialogContent({
className,
children,
ref,
...props
}: ComponentProps<typeof DialogPrimitive.Content>) {
const t = useTranslations();
return (
<DialogPortal>
@@ -59,55 +63,47 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
}
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-1.5 text-center sm:text-start', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
export function DialogHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<div className={cn('flex flex-col gap-1.5 text-center sm:text-start', className)} {...props} />
);
}
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';
export function DialogFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2', className)}
{...props}
/>
);
}
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
export function DialogTitle({
className,
ref,
...props
}: ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
);
}
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-fd-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
export function DialogDescription({
className,
ref,
...props
}: ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-fd-muted-foreground', className)}
{...props}
/>
);
}
@@ -0,0 +1,14 @@
'use client';
import { useAnchorId } from '@/utils/auto-anchor.client';
import type { ComponentProps } from 'react';
import { Heading as BaseHeading } from 'fumadocs-ui/components/heading';
export function Heading({
id: _id,
depth,
...props
}: ComponentProps<'h1'> & { id: string; depth: number }) {
const id = useAnchorId([_id]);
return <BaseHeading id={id} as={`h${depth}` as `h1`} {...props} />;
}
@@ -33,7 +33,7 @@ export function SelectTabs({
export function SelectTab({
value,
anchorSegments: segments,
anchorSegments,
...props
}: ComponentProps<'div'> & {
value: string;
@@ -41,25 +41,28 @@ export function SelectTab({
anchorSegments?: string[];
}) {
const { value: currentValue, setValue } = use(Context)!;
const id = useAnchorId(...(segments ?? []));
const id = useAnchorId(anchorSegments ?? false);
useEffect(() => {
const hash = window.location.hash.slice(1);
if (!hash || !segments) return;
if (anchorIdStartsWith(hash, id)) setValue(value);
}, [id, segments, value, setValue]);
if (id && anchorIdStartsWith(window.location.hash.slice(1), id)) setValue(value);
}, [id, value, setValue]);
if (value !== currentValue) return;
const content = <div {...props} />;
return segments ? <AnchorSection segments={segments}>{content}</AnchorSection> : content;
return anchorSegments ? (
<AnchorSection segments={anchorSegments}>{content}</AnchorSection>
) : (
content
);
}
export function SelectTabTrigger({
items,
className,
placeholder,
...props
}: ComponentProps<typeof SelectTrigger> & {
placeholder?: ReactNode;
items: {
label: ReactNode;
value: string;
@@ -70,7 +73,9 @@ export function SelectTabTrigger({
return (
<Select value={value ?? ''} onValueChange={setValue}>
<SelectTrigger className={cn('not-prose w-fit min-w-0 *:min-w-0', className)} {...props}>
<SelectValue>{value && items.find((item) => item.value === value)?.label}</SelectValue>
<SelectValue placeholder={placeholder}>
{value && items.find((item) => item.value === value)?.label}
</SelectValue>
</SelectTrigger>
<SelectContent>
{items.map(({ label, value }) => (
-14
View File
@@ -125,20 +125,6 @@ export function createClientAPIPage({
provider: options.playground?.provider ?? renderPlaygroundProviderDefault,
render: options.playground?.render ?? renderPlaygroundDefault,
},
renderHeading(depth, text, props) {
const id = typeof text === 'string' ? slug(text) : props?.id;
if (!id) throw new Error("missing 'id' for non-string children");
if (options.renderHeading) {
return options.renderHeading({ id, children: text, ...props }, depth);
}
return (
<Heading id={id} key={id} as={`h${depth}` as `h1`} {...props}>
{text}
</Heading>
);
},
renderMarkdown(text) {
if (options.renderMarkdown) return options.renderMarkdown(text);
processor ??= createMarkdownProcessor();
+1 -1
View File
@@ -108,7 +108,7 @@ export function CopyTypeScriptPanel({
return (
<div
className={cn(
'flex items-start justify-between gap-2 bg-fd-card text-fd-card-foreground border rounded-xl p-3 not-prose mb-4 last:mb-0',
'flex items-start justify-between gap-2 bg-fd-card text-fd-card-foreground border rounded-xl p-3 not-prose',
className,
)}
>
+115 -141
View File
@@ -1,8 +1,10 @@
import { type ComponentProps, Fragment, use, useMemo, type ReactNode } from 'react';
import type {
CallbackObject,
HttpMethods,
MediaTypeObject,
MethodInformation,
OperationObject,
PathItemObject,
RenderContext,
SecuritySchemeObject,
ServerObject,
@@ -28,6 +30,7 @@ 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';
import { Heading } from '@/ui/components/heading';
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
@@ -67,9 +70,9 @@ export function Operation({
headNode = (
<div className="flex gap-2 items-center justify-between">
{ctx.renderHeading(headingLevel, title, {
className: 'my-0!',
})}
<Heading id={title} depth={headingLevel} className="my-0!">
{title}
</Heading>
{method.deprecated && (
<Badge color="yellow" className="text-xs not-prose">
<I18nLabel label="deprecated" />
@@ -92,10 +95,9 @@ export function Operation({
bodyNode = (
<SelectTabs defaultValue={items[0].value}>
<div className="flex gap-2 items-center justify-between mt-10">
{ctx.renderHeading(headingLevel, <I18nLabel label="titleRequestBody" />, {
id: 'request-body',
className: 'my-0!',
})}
<Heading id="request-body" depth={headingLevel} className="my-0!">
<I18nLabel label="titleRequestBody" />
</Heading>
{contentTypes.length > 1 ? (
<SelectTabTrigger items={items} className="font-medium" />
) : (
@@ -123,10 +125,9 @@ export function Operation({
responseNode = (
<>
{ctx.renderHeading(headingLevel, <I18nLabel label="titleResponseBody" />, {
id: 'response-body',
})}
<Heading id="response-body" depth={headingLevel}>
<I18nLabel label="titleResponseBody" />
</Heading>
<Accordions type="multiple">
{statuses.map((status) => (
<ResponseAccordion key={status} status={status} operation={method} ctx={ctx} />
@@ -142,9 +143,9 @@ export function Operation({
return (
<Fragment key={type}>
{ctx.renderHeading(headingLevel, <I18nLabel label={`${type}Parameters`} />, {
id: `parameters-${type}`,
})}
<Heading id={`parameters-${type}`} depth={headingLevel}>
<I18nLabel label={`${type}Parameters`} />
</Heading>
<AnchorSection segments={['parameters', type]}>
<div className="flex flex-col">
{params.map(
@@ -205,10 +206,9 @@ export function Operation({
authNode = (
<SelectTabs defaultValue={items[0].value}>
<div className="flex items-start justify-between gap-2 mt-10">
{ctx.renderHeading(headingLevel, <I18nLabel label="authorization" />, {
id: 'authorization',
className: 'my-0!',
})}
<Heading id="authorization" depth={headingLevel} className="my-0!">
<I18nLabel label="authorization" />
</Heading>
{items.length > 1 ? (
<SelectTabTrigger items={items} />
) : (
@@ -229,32 +229,57 @@ export function Operation({
);
}
const callbacks = method.callbacks ? Object.entries(method.callbacks) : null;
if (callbacks && callbacks.length > 0) {
const items = callbacks.map(([key]) => ({
label: <code className="text-xs">{key}</code>,
value: key,
}));
const webhookCallbacks: {
name: string;
path: string;
method: HttpMethods;
callback: NoReference<PathItemObject>;
operation: NoReference<OperationObject>;
}[] = [];
for (const [name, callbacks] of Object.entries(method.callbacks ?? {})) {
for (const [path, callback] of Object.entries(callbacks)) {
for (const method of methodKeys) {
if (!callback[method]) continue;
webhookCallbacks.push({ name, path, method, callback, operation: callback[method] });
}
}
}
if (webhookCallbacks.length > 0) {
callbacksNode = (
<SelectTabs defaultValue={items[0].value}>
<div className="flex justify-between gap-2 items-end mt-10">
{ctx.renderHeading(headingLevel, <I18nLabel label="titleCallbacks" />, {
id: 'callbacks',
className: 'my-0!',
})}
{callbacks.length > 1 ? (
<SelectTabTrigger items={items} className="font-medium" />
) : (
<p className="text-fd-muted-foreground not-prose">{items[0].label}</p>
)}
</div>
{callbacks.map(([name, callback]) => (
<SelectTab key={name} value={name} anchorSegments={['webhook', name]}>
<WebhookCallback callback={callback} ctx={ctx} headingLevel={headingLevel} />
</SelectTab>
))}
</SelectTabs>
<>
<Heading id="callbacks" depth={headingLevel}>
<I18nLabel label="titleCallbacks" />
</Heading>
<Accordions type="multiple">
{webhookCallbacks.map((item, i) => (
<AccordionItem
key={i}
value={`${item.name}\0${item.path}\0${item.method}`}
anchorSegments={['callbacks', item.name, item.path, item.method]}
>
<AccordionHeader className="flex-col gap-3">
<AccordionTrigger className="font-mono">{item.name}</AccordionTrigger>
<div className="flex items-center gap-2 text-xs ps-4.5">
<MethodLabel>{item.method}</MethodLabel>
<code className="text-fd-muted-foreground">{item.path}</code>
</div>
</AccordionHeader>
<AccordionContent>
<div className="border p-3 ps-4.5 mb-2 @container prose-no-margin rounded-xl">
<Operation
type="webhook"
path={path}
headingLevel={headingLevel + 1}
method={createMethod(item.method, item.callback, item.operation)}
ctx={ctx}
/>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordions>
</>
);
}
@@ -333,7 +358,7 @@ export function Operation({
} else {
renderWebhookLayout ??= (slots) => (
<div className="flex flex-col-reverse gap-x-6 gap-y-4 @4xl:flex-row @4xl:items-start">
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 prose-no-margin">
{slots.header}
{slots.description}
{slots.authSchemes}
@@ -382,7 +407,7 @@ function RequestBodyContentItem({
return (
<>
{ts && <CopyTypeScriptPanel name="request body" code={ts} className="mt-4" />}
{ts && <CopyTypeScriptPanel name="request body" code={ts} className="my-4 last:mb-0" />}
{content.schema && (
<Schema
client={{
@@ -411,53 +436,46 @@ 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) {
selectorNode =
items.length === 1 ? (
<p className="text-fd-muted-foreground not-prose">{items[0].label}</p>
) : (
<SelectTabTrigger items={items} />
);
wrapper = (children) => <SelectTabs defaultValue={items[0].value}>{children}</SelectTabs>;
}
return wrapper(
<AccordionItem value={status}>
<AccordionHeader>
<AccordionTrigger className="font-mono">{status}</AccordionTrigger>
{selectorNode}
</AccordionHeader>
<AccordionContent className="ps-4.5">
{response.description && (
<div className="prose-no-margin mb-2">{ctx.renderMarkdown(response.description)}</div>
)}
{contentTypes.map(([type, item]) => (
<SelectTab
key={type}
value={type}
className="mb-2"
anchorSegments={['response', status, type]}
>
<RepsonseAccordionItem
type={type}
status={status}
item={item}
operation={operation}
ctx={ctx}
/>
</SelectTab>
))}
</AccordionContent>
</AccordionItem>,
return (
<AccordionItem
value={status}
anchorSegments={['response', status]}
className="data-[state=open]:border-b-0"
>
<SelectTabs defaultValue={items[0]?.value}>
<AccordionHeader>
<AccordionTrigger className="font-mono">{status}</AccordionTrigger>
{items.length === 1 ? (
<p className="text-fd-muted-foreground not-prose">{items[0].label}</p>
) : (
items.length > 0 && <SelectTabTrigger items={items} />
)}
</AccordionHeader>
<AccordionContent className="ps-4.5 pe-3 border rounded-xl">
{response.description && (
<div className="prose-no-margin mt-3 mb-2">
{ctx.renderMarkdown(response.description)}
</div>
)}
{contentTypes.map(([type, item]) => (
<SelectTab key={type} value={type} anchorSegments={[type]}>
<RepsonseAccordionItem
type={type}
status={status}
item={item}
operation={operation}
ctx={ctx}
/>
</SelectTab>
))}
</AccordionContent>
</SelectTabs>
</AccordionItem>
);
}
@@ -492,66 +510,22 @@ function RepsonseAccordionItem({
return (
<>
{ts && <CopyTypeScriptPanel name="response body" code={ts} />}
{ts && <CopyTypeScriptPanel name="response body" code={ts} className="mb-2" />}
{schema && (
<div className="border p-3 pb-2 rounded-lg">
<Schema
client={{
name: 'response',
as: 'body',
}}
root={schema}
readOnly
ctx={ctx}
/>
</div>
<Schema
client={{
name: 'response',
as: 'body',
}}
root={schema}
readOnly
ctx={ctx}
/>
)}
</>
);
}
function WebhookCallback({
callback,
ctx,
headingLevel,
}: {
callback: NoReference<CallbackObject>;
ctx: RenderContext;
headingLevel: number;
}) {
return (
<Accordions type="single" collapsible>
{Object.entries(callback).map(([path, pathItem]) => {
const pathNodes = methodKeys.map((method) => {
const operation = pathItem[method];
if (!operation) return null;
return (
<div key={method} className="border p-3 my-2 @container prose-no-margin rounded-lg">
<Operation
type="webhook"
path={path}
headingLevel={headingLevel + 1}
method={createMethod(method, pathItem, operation)}
ctx={ctx}
/>
</div>
);
});
return (
<AccordionItem key={path} value={path}>
<AccordionHeader>
<AccordionTrigger className="font-mono">{path}</AccordionTrigger>
</AccordionHeader>
<AccordionContent>{pathNodes}</AccordionContent>
</AccordionItem>
);
})}
</Accordions>
);
}
function AuthScheme({
scheme,
scopes,
+10 -22
View File
@@ -22,7 +22,7 @@ import type {
SchemaUIGeneratedData,
} from '@/ui/schema';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { CheckIcon, ChevronDown, FilterIcon, LinkIcon } from 'lucide-react';
import { CheckIcon, 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';
@@ -85,7 +85,7 @@ export interface SchemaUIProps {
}
export function SchemaUI({ name, required = false, as = 'property', generated }: SchemaUIProps) {
const rootId = useAnchorId(name);
const rootId = useAnchorId([name]);
const [path, _setPath] = useState<PathItemType[]>(() => [{ $ref: generated.$root, name }]);
const ref = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -192,7 +192,7 @@ export function SchemaUI({ name, required = false, as = 'property', generated }:
function SchemaDescription({ schema, ...props }: ComponentProps<'div'> & { schema: SchemaData }) {
return (
<div {...props} className={cn('prose-no-margin py-2.5 empty:hidden', props.className)}>
<div {...props} className={cn('prose-no-margin py-4 empty:hidden', props.className)}>
{schema.description}
{schema.infoTags && schema.infoTags.length > 0 && (
<div className="flex flex-row gap-2 flex-wrap mt-2 not-prose empty:hidden">
@@ -243,7 +243,7 @@ function ObjectProperty({
[highlighted],
),
)}
className={cn('text-sm border-t py-4 first:border-t-0', props.className)}
className={cn('group/property 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">
@@ -329,7 +329,7 @@ function PathItemBody({
<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>
<SelectValue>{items.find((item) => item.value === value)?.label}</SelectValue>
</SelectTrigger>
<SelectContent>
{items.map(({ label, value }) => (
@@ -461,17 +461,13 @@ function ObjectSearchContent({
function InfoTag({ tag }: { tag: InfoTag }) {
const ref = useRef<HTMLElement>(null);
const [isTruncated, setTruncated] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
// assume the tag width will never change
setTruncated(element.scrollWidth !== element.offsetWidth);
}, []);
return (
<div className="flex flex-row items-start gap-2 bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md max-w-full">
<button
className="inline-flex text-start items-start gap-2 bg-fd-secondary border rounded-lg text-xs p-1.5 shadow-md max-w-full"
onClick={() => setOpen((prev) => !prev)}
>
<span className="font-medium">{tag.label}</span>
<code
ref={ref}
@@ -482,15 +478,7 @@ function InfoTag({ tag }: { tag: InfoTag }) {
>
{tag.value}
</code>
{isTruncated && (
<button
className={cn(buttonVariants({ size: 'icon-xs', variant: 'ghost' }))}
onClick={() => setOpen((prev) => !prev)}
>
<ChevronDown />
</button>
)}
</div>
</button>
);
}
@@ -15,6 +15,11 @@ export function AnchorSection({ segments, children }: { segments: string[]; chil
);
}
export function useAnchorId(...segments: string[]) {
export function useAnchorId(segments: false): null;
export function useAnchorId(segments: string[]): string;
export function useAnchorId(segments: string[] | false): string | null;
export function useAnchorId(segments: string[] | false): string | null {
if (!segments) return null;
return anchorSegments(...use(AnchorContext), ...segments);
}
-3
View File
@@ -16,9 +16,6 @@ export async function renderContextFrom(input: string): Promise<RenderContext> {
renderCodeBlock(_lang, code) {
return code;
},
renderHeading(_depth, text) {
return text;
},
clientBoundary: ClientBoundary,
renderMarkdown(text) {
return text;