OpenAPI: Support useScalar option

This commit is contained in:
Fuma Nama
2025-01-25 18:04:03 +08:00
parent 983b8a6ba7
commit 471478b6b3
23 changed files with 1676 additions and 165 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support `useScalar` option
+1
View File
@@ -53,6 +53,7 @@
body {
overscroll-behavior-y: none;
background-color: var(--color-fd-background);
}
.dark {
+1
View File
@@ -18,6 +18,7 @@
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.7",
"@scalar/api-client-react": "^1.1.17",
"@shikijs/rehype": "^2.1.0",
"@theguild/remark-mermaid": "^0.2.0",
"class-variance-authority": "^0.7.1",
+1
View File
@@ -18,6 +18,7 @@ export async function generateDocs() {
input: ['./museum.yaml'],
output: './content/docs/openapi',
per: 'operation',
groupBy: 'tag',
}),
Typescript.generateFiles({
input: ['./content/docs/**/*.model.mdx'],
+8
View File
@@ -50,6 +50,7 @@
"github-slugger": "^2.0.0",
"hast-util-to-jsx-runtime": "^2.3.2",
"js-yaml": "^4.1.0",
"next-themes": "^0.4.4",
"openapi-sampler": "^1.6.1",
"react-hook-form": "^7.54.2",
"remark": "^15.0.1",
@@ -58,6 +59,7 @@
"xml-js": "^1.6.11"
},
"devDependencies": {
"@scalar/api-client-react": "^1.1.17",
"@types/js-yaml": "^4.0.9",
"@types/node": "22.10.9",
"@types/openapi-sampler": "^1.0.3",
@@ -71,10 +73,16 @@
"tsconfig": "workspace:*"
},
"peerDependencies": {
"@scalar/api-client-react": "*",
"next": "14.x.x || 15.x.x",
"react": "18.x.x || 19.x.x",
"react-dom": "18.x.x || 19.x.x"
},
"peerDependenciesMeta": {
"@scalar/api-client-react": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
@@ -10,6 +10,7 @@ import {
type ParsedSchema,
} from '@/utils/schema';
import { getSecurities } from '@/utils/get-security';
import { ScalarPlayground } from '@/ui';
interface BaseRequestField {
name: string;
@@ -103,6 +104,10 @@ export function Playground({
method: MethodInformation;
ctx: RenderContext;
}): ReactNode {
if (ctx.useScalar) {
return <ScalarPlayground method={method.method} path={path} />;
}
let currentId = 0;
const bodyContent = method.requestBody?.content;
const mediaType = bodyContent ? getPreferredType(bodyContent) : undefined;
+9 -1
View File
@@ -11,6 +11,7 @@ import {
Property,
ObjectCollapsible,
APIPlayground,
APIInfoHeader,
} from '@/ui';
import type { RenderContext, ServerObject } from '@/types';
@@ -93,6 +94,7 @@ export type {
export function createRenders(
shikiOptions: RenderContext['shikiOptions'],
useScalar: boolean,
): Renderer {
return {
Root: (props) => (
@@ -101,7 +103,13 @@ export function createRenders(
</Root>
),
API,
APIInfo,
APIInfo: ({ children, head, ...props }) => (
<APIInfo>
{head}
{!useScalar ? <APIInfoHeader {...props} /> : null}
{children}
</APIInfo>
),
APIExample,
Responses: Tabs,
Response: Tab,
+6 -1
View File
@@ -189,7 +189,12 @@ export function Schema({
return (
<renderer.ObjectCollapsible
key={`mentioned:${idx.toString()}`}
name={s.title ?? `Object ${(idx + 1).toString()}`}
name={
s.title ??
(mentionedObjectTypes.length === 1
? 'Show Attributes'
: `Object ${idx + 1}`)
}
>
<Schema
name="element"
+12 -2
View File
@@ -8,6 +8,7 @@ import type { OpenAPIV3_1 } from 'openapi-types';
import type { NoReference } from '@/utils/schema';
import { type DocumentInput, processDocument } from '@/utils/process-document';
import { getUrl } from '@/utils/server-url';
import { ScalarProvider } from '@/ui';
type ApiPageContextProps = Pick<
Partial<RenderContext>,
@@ -16,6 +17,7 @@ type ApiPageContextProps = Pick<
| 'generateCodeSamples'
| 'proxyUrl'
| 'showResponseSchema'
| 'useScalar'
>;
export interface ApiPageProps extends ApiPageContextProps {
@@ -63,7 +65,8 @@ export async function APIPage(props: ApiPageProps) {
const processed = await processDocument(props.document, disableCache);
const ctx = await getContext(processed, props);
const { document } = processed;
return (
const children = (
<ctx.renderer.Root baseUrl={ctx.baseUrl} servers={ctx.servers}>
{operations?.map((item) => {
const pathItem = document.paths?.[item.path];
@@ -109,6 +112,12 @@ export async function APIPage(props: ApiPageProps) {
})}
</ctx.renderer.Root>
);
return ctx.useScalar ? (
<ScalarProvider spec={processed.downloaded}>{children}</ScalarProvider>
) : (
children
);
}
export async function getContext(
@@ -124,12 +133,13 @@ export async function getContext(
const server = servers[0];
return {
useScalar: options.useScalar ?? false,
document: document,
dereferenceMap,
proxyUrl: options.proxyUrl,
showResponseSchema: options.showResponseSchema,
renderer: {
...createRenders(options.shikiOptions),
...createRenders(options.shikiOptions, options.useScalar ?? false),
...options.renderer,
},
shikiOptions: options.shikiOptions,
+5
View File
@@ -33,6 +33,11 @@ type Awaitable<T> = T | Promise<T>;
export type DereferenceMap = Map<unknown, string>;
export interface RenderContext {
/**
* Use Scalar for API Playground
*/
useScalar: boolean;
/**
* The url of proxy to avoid CORS issues
*/
+3
View File
@@ -18,6 +18,9 @@ export const APIPlayground = dynamic(() =>
export const ServerSelect = dynamic(() => import('./server-select'));
export const ScalarPlayground = dynamic(() => import('./scalar/index'));
export const ScalarProvider = dynamic(() => import('./scalar/provider'));
export function Root({
children,
baseUrl,
@@ -0,0 +1,35 @@
import type { HTMLAttributes, ReactNode } from 'react';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { cn } from 'fumadocs-ui/components/api';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ChevronDown } from 'lucide-react';
export function CollapsiblePanel({
title,
children,
...props
}: HTMLAttributes<HTMLDivElement> & {
title: ReactNode;
children: ReactNode;
}) {
return (
<Collapsible {...props}>
<CollapsibleTrigger
className={cn(
buttonVariants({ color: 'outline', size: 'sm' }),
'group rounded-full px-2 py-1.5 text-fd-muted-foreground',
)}
>
{title}
<ChevronDown className="size-4 group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent className="-mx-2">
<div className="flex flex-col gap-4 p-2 pb-0">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
+2 -2
View File
@@ -81,7 +81,7 @@ const FormItem = forwardRef<
FormItem.displayName = 'FormItem';
export const labelVariants = cva(
'font-mono text-xs text-fd-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'font-medium text-[13px] text-fd-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const FormLabel = forwardRef<
@@ -93,7 +93,7 @@ const FormLabel = forwardRef<
return (
<label
ref={ref}
className={cn(labelVariants(), isError && 'text-red-500', className)}
className={cn(isError && 'text-red-500', className)}
htmlFor={formItemId}
{...props}
/>
+29 -30
View File
@@ -1,11 +1,17 @@
import { cn } from 'fumadocs-ui/components/api';
import { Fragment, type HTMLAttributes, type ReactNode } from 'react';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { badgeVariants, getBadgeColor } from '@/ui/components/variants';
import type { APIInfoProps, PropertyProps } from '@/render/renderer';
import { CopyRouteButton, ServerSelect } from '@/ui/client';
import { CollapsiblePanel } from '@/ui/components/collapsible';
export { Root, useSchemaContext, APIPlayground } from './client';
export {
Root,
useSchemaContext,
APIPlayground,
ScalarPlayground,
ScalarProvider,
} from './client';
function Route({ route }: { route: string }): ReactNode {
const segments = route.split('/').filter((part) => part.length > 0);
@@ -28,34 +34,29 @@ function Route({ route }: { route: string }): ReactNode {
export function APIInfo({
className,
route,
badgeClassname,
method = 'GET',
head,
...props
}: APIInfoProps &
HTMLAttributes<HTMLDivElement> & {
badgeClassname?: string;
}) {
}: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn('min-w-0 flex-1', className)} {...props}>
{head}
<div className="not-prose mb-4 rounded-lg border bg-fd-card p-3 text-fd-card-foreground shadow-lg">
<div className="-mx-1 flex flex-row items-center gap-1.5">
<span
className={cn(
badgeVariants({ color: getBadgeColor(method) }),
badgeClassname,
)}
>
{method}
</span>
<Route route={route} />
<CopyRouteButton className="ms-auto size-6 p-1.5" route={route} />
</div>
<ServerSelect />
{props.children}
</div>
);
}
export function APIInfoHeader({
method,
route,
}: Omit<APIInfoProps, 'head' | 'children'>) {
return (
<div className="not-prose mb-4 rounded-lg border bg-fd-card p-3 text-fd-card-foreground shadow-lg">
<div className="flex flex-row items-center gap-1.5">
<span className={cn(badgeVariants({ color: getBadgeColor(method) }))}>
{method}
</span>
<Route route={route} />
<CopyRouteButton className="ms-auto size-6 p-1.5" route={route} />
</div>
<div className="prose-no-margin">{props.children}</div>
<ServerSelect />
</div>
);
}
@@ -89,7 +90,7 @@ export function Property({
children,
}: PropertyProps) {
return (
<div className="mb-4 rounded-lg border bg-fd-card p-3 prose-no-margin">
<div className="mb-4 rounded-xl border bg-fd-card p-3 prose-no-margin">
<h4 className="flex flex-row flex-wrap items-center gap-4">
<code>{name}</code>
{required ? (
@@ -128,8 +129,6 @@ export function ObjectCollapsible(props: {
children: ReactNode;
}) {
return (
<Accordions type="single">
<Accordion title={props.name}>{props.children}</Accordion>
</Accordions>
<CollapsiblePanel title={props.name}>{props.children}</CollapsiblePanel>
);
}
+25 -42
View File
@@ -7,14 +7,8 @@ import {
useState,
useEffect,
type FC,
type ReactNode,
} from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { cn, buttonVariants } from 'fumadocs-ui/components/api';
import type {
FieldPath,
@@ -39,7 +33,7 @@ import { type DynamicField, SchemaContext } from '../contexts/schema';
import { getStatusInfo } from '@/ui/playground/status-info';
import { getUrl } from '@/utils/server-url';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { ChevronDown } from 'lucide-react';
import { CollapsiblePanel } from '@/ui/components/collapsible';
interface FormValues {
authorization: string;
@@ -191,21 +185,12 @@ export function APIPlayground({
<form
{...props}
className={cn(
'not-prose flex flex-col gap-5 rounded-xl border bg-fd-card p-3',
'not-prose flex flex-col gap-2 rounded-xl border bg-fd-card p-3',
props.className,
)}
onSubmit={onSubmit as React.FormEventHandler}
>
<div className="flex flex-row gap-2">
<RouteDisplay route={route} />
<button
type="submit"
className={cn(buttonVariants({ color: 'secondary' }))}
disabled={testQuery.isLoading}
>
Send
</button>
</div>
<FormHeader route={route} isLoading={testQuery.isLoading} />
{authorization
? renderCustomField('authorization', authorization, fields.auth)
@@ -266,26 +251,6 @@ export function APIPlayground({
);
}
function CollapsiblePanel({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<Collapsible className="-m-2">
<CollapsibleTrigger className="group flex w-full flex-row items-center justify-between p-2 font-medium">
{title}
<ChevronDown className="size-4 group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-4 p-2">{children}</div>
</CollapsibleContent>
</Collapsible>
);
}
function createPathnameFromInput(
route: string,
path: Record<string, unknown>,
@@ -312,7 +277,13 @@ function createPathnameFromInput(
: pathname;
}
function RouteDisplay({ route }: { route: string }): ReactElement {
function FormHeader({
route,
isLoading,
}: {
route: string;
isLoading: boolean;
}) {
const [path, query] = useWatch<FormValues, ['path', 'query']>({
name: ['path', 'query'],
});
@@ -323,9 +294,21 @@ function RouteDisplay({ route }: { route: string }): ReactElement {
);
return (
<code className="flex-1 overflow-auto text-nowrap rounded-lg border bg-fd-secondary px-2 py-1.5 text-sm text-fd-secondary-foreground">
{pathname}
</code>
<div className="flex flex-row gap-2 mb-2">
<code className="inline-flex flex-row items-center rounded-full flex-1 overflow-auto text-nowrap border px-2 text-xs text-fd-secondary-foreground bg-fd-secondary">
{pathname}
</code>
<button
type="submit"
className={cn(
buttonVariants({ color: 'primary' }),
'px-3 py-1.5 rounded-full',
)}
disabled={isLoading}
>
Send
</button>
</div>
);
}
@@ -88,8 +88,8 @@ function InputContainer({
}: InputContainerProps): React.ReactElement {
return (
<div {...props} className={cn('flex flex-col gap-1', props.className)}>
<div className={cn(labelVariants(), 'inline-flex items-center gap-1')}>
{name}
<div className="inline-flex items-center gap-1">
<span className={cn(labelVariants())}>{name}</span>
{required ? <span className="text-red-500">*</span> : null}
<div className="flex-1" />
{type ? (
@@ -402,7 +402,7 @@ export function InputField({
header={
<>
<FormLabel className="inline-flex items-center gap-1">
{name}
<span className={cn(labelVariants())}>{name}</span>
{field.isRequired ? <span className="text-red-500">*</span> : null}
<code className="ms-auto text-xs text-fd-muted-foreground">
{field.type}
+74
View File
@@ -0,0 +1,74 @@
'use client';
import { cn } from 'fumadocs-ui/components/api';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { useApiClientModal } from '@scalar/api-client-react';
import { cva, type VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'rounded-full border inline-flex flex-row items-center py-1.5 px-2 text-xs font-medium',
{
variants: {
color: {
green: 'bg-green-400/20 text-green-600 dark:text-green-400',
yellow: 'bg-yellow-400/20 text-yellow-600 dark:text-yellow-400',
red: 'bg-red-400/20 text-red-600 dark:text-red-400',
blue: 'bg-blue-400/20 text-blue-600 dark:text-blue-400',
orange: 'bg-orange-400/20 text-orange-600 dark:text-orange-400',
},
},
},
);
function getBadgeColor(
method: string,
): VariantProps<typeof badgeVariants>['color'] {
switch (method) {
case 'PUT':
return 'yellow';
case 'PATCH':
return 'orange';
case 'POST':
return 'blue';
case 'DELETE':
return 'red';
default:
return 'green';
}
}
export default function ScalarPlayground({
path,
method,
}: {
path: string;
method: string;
}) {
const client = useApiClientModal();
return (
<div className="flex flex-row gap-2 p-3 rounded-xl border bg-fd-card text-fd-card-foreground">
<span
className={cn(
badgeVariants({
color: getBadgeColor(method),
}),
)}
>
{method.toUpperCase()}
</span>
<code className="inline-flex flex-row items-center rounded-full flex-1 overflow-auto text-nowrap border px-2 text-xs text-fd-secondary-foreground bg-fd-secondary">
{path}
</code>
<button
type="submit"
className={cn(
buttonVariants({ color: 'primary' }),
'px-3 py-1.5 rounded-full',
)}
onClick={() => client?.open({ path, method })}
>
Test
</button>
</div>
);
}
@@ -0,0 +1,34 @@
'use client';
import { ApiClientModalProvider } from '@scalar/api-client-react';
import { type ReactNode, useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
export default function ScalarProvider({
spec,
children,
}: {
spec: object;
children: ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const { resolvedTheme } = useTheme();
useEffect(() => {
setMounted(true);
}, []);
return (
<div className={mounted ? `${resolvedTheme}-mode` : ''}>
<ApiClientModalProvider
configuration={{
themeId: 'moon',
spec: {
content: spec,
},
}}
>
{children}
</ApiClientModalProvider>
</div>
);
}
+68 -81
View File
@@ -1,11 +1,5 @@
'use client';
import { useApiContext, useServerSelectContext } from '@/ui/contexts/api';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from 'fumadocs-ui/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import {
Select,
SelectContent,
@@ -14,6 +8,7 @@ import {
SelectValue,
} from '@/ui/components/select';
import { Input } from '@/ui/components/input';
import { CollapsiblePanel } from '@/ui/components/collapsible';
export default function ServerSelect() {
const { servers } = useApiContext();
@@ -26,81 +21,73 @@ export default function ServerSelect() {
: undefined;
return (
<Collapsible className="-m-2 mt-2">
<CollapsibleTrigger className="flex w-full flex-row items-center justify-between p-2 text-xs font-medium text-fd-muted-foreground">
Configure Server
<ChevronDown className="size-4" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="flex flex-col gap-4 p-2">
<Select value={server?.url} onValueChange={setServer}>
<SelectTrigger className="h-auto break-all">
<SelectValue />
</SelectTrigger>
<SelectContent>
{servers.map((item) => (
<SelectItem key={item.url} value={item.url}>
{item.url}
<p className="text-start text-xs text-fd-muted-foreground">
{item.description}
</p>
</SelectItem>
))}
</SelectContent>
</Select>
{Object.entries(schema?.variables ?? {}).map(([key, variable]) => {
if (!server) return;
const id = `fd_server_select_${key}`;
<CollapsiblePanel title="Configure Server" className="mt-2">
<Select value={server?.url} onValueChange={setServer}>
<SelectTrigger className="h-auto break-all">
<SelectValue />
</SelectTrigger>
<SelectContent>
{servers.map((item) => (
<SelectItem key={item.url} value={item.url}>
{item.url}
<p className="text-start text-xs text-fd-muted-foreground">
{item.description}
</p>
</SelectItem>
))}
</SelectContent>
</Select>
{Object.entries(schema?.variables ?? {}).map(([key, variable]) => {
if (!server) return;
const id = `fd_server_select_${key}`;
return (
<fieldset key={key} className="flex flex-col gap-1">
<label
className="font-mono text-xs text-fd-foreground"
htmlFor={id}
>
{key}
</label>
<p className="text-xs text-fd-muted-foreground empty:hidden">
{variable.description}
</p>
{variable.enum ? (
<Select
value={server.variables[key]}
onValueChange={(v) =>
setServerVariables({
...server?.variables,
[key]: v,
})
}
>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{variable.enum.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={server.variables[key]}
onChange={(e) =>
setServerVariables({
...server?.variables,
[key]: e.target.value,
})
}
/>
)}
</fieldset>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
return (
<fieldset key={key} className="flex flex-col gap-1">
<label
className="font-mono text-xs text-fd-foreground"
htmlFor={id}
>
{key}
</label>
<p className="text-xs text-fd-muted-foreground empty:hidden">
{variable.description}
</p>
{variable.enum ? (
<Select
value={server.variables[key]}
onValueChange={(v) =>
setServerVariables({
...server?.variables,
[key]: v,
})
}
>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{variable.enum.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={id}
value={server.variables[key]}
onChange={(e) =>
setServerVariables({
...server?.variables,
[key]: e.target.value,
})
}
/>
)}
</fieldset>
);
})}
</CollapsiblePanel>
);
}
@@ -10,6 +10,7 @@ export type DocumentInput = string | OpenAPIV3_1.Document | OpenAPIV3.Document;
export type ProcessedDocument = {
document: NoReference<Document>;
dereferenceMap: DereferenceMap;
downloaded: Document;
};
const cache = new Map<string, ProcessedDocument>();
@@ -42,6 +43,7 @@ export async function processDocument(
const processed: ProcessedDocument = {
document: dereferenced as NoReference<Document>,
dereferenceMap,
downloaded: loaded.specification as Document,
};
if (!disableCache && typeof document === 'string') {
+1 -1
View File
@@ -18,7 +18,7 @@
}
.dark {
--color-fd-background: hsl(0, 0%, 8.04%);
--color-fd-background: hsl(0, 0%, 7.04%);
--color-fd-foreground: hsl(0, 0%, 92%);
--color-fd-muted: hsl(0, 0%, 12.9%);
--color-fd-muted-foreground: hsl(0, 0%, 60.9%);
+2
View File
@@ -5,6 +5,8 @@ export const buttonVariants = cva(
{
variants: {
color: {
primary:
'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80',
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
secondary:
+1345 -2
View File
File diff suppressed because it is too large Load Diff