mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
feat(openapi): better Webhook UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
---
|
||||
|
||||
Deprecate `renderHeading` option
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user