mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}.`);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user