OpenAPI: Support custom shiki options

This commit is contained in:
fuma-nama
2024-10-20 17:25:21 +08:00
parent 547665de1c
commit 129923ed0c
12 changed files with 164 additions and 107 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Support custom `shiki` options
+8 -1
View File
@@ -26,7 +26,14 @@ export const blog = loader({
source: createMDXSource(blogPosts, []),
});
export const openapi = createOpenAPI();
export const openapi = createOpenAPI({
shikiOptions: {
themes: {
dark: 'vesper',
light: 'vitesse-light',
},
},
});
export type Page = InferPageType<typeof source>;
export type Meta = InferMetaType<typeof source>;
+1 -1
View File
@@ -31,7 +31,7 @@ export async function updateOramaAi(): Promise<void> {
const files = await fg([
'./content/docs/**/*.mdx',
'!*.model.mdx',
'!./content/docs/ui/museum/**/*',
'!./content/docs/openapi/**/*',
]);
const records: unknown[] = [];
+6 -2
View File
@@ -4,22 +4,26 @@ import { codeToHast } from 'shiki';
import { type Jsx, toJsxRuntime } from 'hast-util-to-jsx-runtime';
import { jsx, jsxs } from 'react/jsx-runtime';
import { sharedTransformers } from '@/utils/shiki';
import type { RenderContext } from '@/types';
export type CodeBlockProps = HTMLAttributes<HTMLPreElement> & {
code: string;
lang: string;
options?: RenderContext['shikiOptions'];
};
export async function CodeBlock({
code,
lang,
...options
options,
...rest
}: CodeBlockProps): Promise<React.ReactElement> {
const html = await codeToHast(code, {
lang,
defaultColor: false,
themes: { light: 'github-light', dark: 'github-dark' },
transformers: sharedTransformers,
...options,
});
const codeblock = toJsxRuntime(html, {
@@ -28,7 +32,7 @@ export async function CodeBlock({
jsxs: jsxs as Jsx,
Fragment,
components: {
pre: (props) => <Base.Pre {...props} {...options} />,
pre: (props) => <Base.Pre {...props} {...rest} />,
},
});
+46 -31
View File
@@ -12,6 +12,7 @@ import {
ObjectCollapsible,
APIPlayground,
} from '@/ui';
import type { RenderContext } from '@/types';
export interface ResponsesProps {
items: string[];
@@ -87,34 +88,48 @@ export type {
ReferenceSchema,
} from '@/render/playground';
export const defaultRenderer: Renderer = {
Root,
API,
APIInfo,
APIExample,
Responses: Tabs,
Response: Tab,
ResponseTypes: (props) => (
<Accordions
type="single"
className="!-m-4 border-none pt-2"
defaultValue="Response"
>
{props.children}
</Accordions>
),
ResponseType: (props) => (
<Accordion title={props.label}>
<CodeBlock code={props.code} lang={props.lang} />
</Accordion>
),
Property,
ObjectCollapsible,
Requests: (props) => <Tabs groupId="fumadocs_openapi_requests" {...props} />,
Request: (props) => (
<Tab value={props.name}>
<CodeBlock lang={props.language} code={props.code} />
</Tab>
),
APIPlayground,
};
export function createRenders(
shikiOptions: RenderContext['shikiOptions'],
): Renderer {
return {
Root: (props) => (
<Root shikiOptions={shikiOptions} {...props}>
{props.children}
</Root>
),
API,
APIInfo,
APIExample,
Responses: Tabs,
Response: Tab,
ResponseTypes: (props) => (
<Accordions
type="single"
className="!-m-4 border-none pt-2"
defaultValue="Response"
>
{props.children}
</Accordions>
),
ResponseType: (props) => (
<Accordion title={props.label}>
<CodeBlock code={props.code} lang={props.lang} options={shikiOptions} />
</Accordion>
),
Property,
ObjectCollapsible,
Requests: (props) => (
<Tabs groupId="fumadocs_openapi_requests" {...props} />
),
Request: (props) => (
<Tab value={props.name}>
<CodeBlock
lang={props.language}
code={props.code}
options={shikiOptions}
/>
</Tab>
),
APIPlayground,
};
}
+4 -3
View File
@@ -5,12 +5,12 @@ import Parser from '@apidevtools/json-schema-ref-parser';
import { Operation } from '@/render/operation';
import type { RenderContext } from '@/types';
import { createMethod } from '@/schema/method';
import { defaultRenderer, type Renderer } from '@/render/renderer';
import { createRenders, type Renderer } from '@/render/renderer';
export interface ApiPageProps
extends Pick<
RenderContext,
'generateCodeSamples' | 'generateTypeScriptSchema'
'generateCodeSamples' | 'generateTypeScriptSchema' | 'shikiOptions'
> {
document: string | OpenAPI.Document;
@@ -77,9 +77,10 @@ function getContext(
return {
document,
renderer: {
...defaultRenderer,
...createRenders(options.shikiOptions),
...options.renderer,
},
shikiOptions: options.shikiOptions,
generateTypeScriptSchema: options.generateTypeScriptSchema,
generateCodeSamples: options.generateCodeSamples,
baseUrl: document.servers?.[0].url ?? 'https://example.com',
+8
View File
@@ -3,6 +3,11 @@ import type { default as Slugger } from 'github-slugger';
import { type Renderer } from '@/render/renderer';
import type { EndpointSample } from '@/schema/sample';
import type { CodeSample } from '@/render/operation';
import type {
BuiltinTheme,
CodeOptionsThemes,
CodeToHastOptionsCommon,
} from 'shiki';
export interface RouteInformation {
path: string;
@@ -40,4 +45,7 @@ export interface RenderContext {
* Generate code samples for endpoint.
*/
generateCodeSamples?: (endpoint: EndpointSample) => Awaitable<CodeSample[]>;
shikiOptions?: Omit<CodeToHastOptionsCommon, 'lang'> &
CodeOptionsThemes<BuiltinTheme>;
}
+8 -2
View File
@@ -9,6 +9,7 @@ import { cn, useCopyButton, buttonVariants } from 'fumadocs-ui/components/api';
import dynamic from 'next/dynamic';
import { ApiProvider, useApiContext } from '@/ui/contexts/api';
import { type RootProps } from '@/render/renderer';
import { RenderContext } from '@/types';
export const APIPlayground = dynamic(() =>
import('./playground').then((mod) => mod.APIPlayground),
@@ -18,8 +19,11 @@ export function Root({
children,
baseUrl,
className,
shikiOptions,
...props
}: RootProps & HTMLAttributes<HTMLDivElement>): ReactNode {
}: RootProps & {
shikiOptions: RenderContext['shikiOptions'];
} & HTMLAttributes<HTMLDivElement>): ReactNode {
return (
<div
className={cn(
@@ -28,7 +32,9 @@ export function Root({
)}
{...props}
>
<ApiProvider defaultBaseUrl={baseUrl}>{children}</ApiProvider>
<ApiProvider shikiOptions={shikiOptions} defaultBaseUrl={baseUrl}>
{children}
</ApiProvider>
</div>
);
}
@@ -1,37 +1,39 @@
import { type HTMLAttributes, useLayoutEffect, useState } from 'react';
import { Fragment, type ReactNode, useEffect, useState } from 'react';
import * as Base from 'fumadocs-ui/components/codeblock';
import { useApiContext } from '@/ui/contexts/api';
import { sharedTransformers } from '@/utils/shiki';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import { jsx, jsxs } from 'react/jsx-runtime';
export type CodeBlockProps = HTMLAttributes<HTMLPreElement> & {
export type CodeBlockProps = {
code: string;
lang?: string;
};
export function CodeBlock({
code,
lang = 'json',
...props
}: CodeBlockProps): React.ReactElement {
const { highlighter } = useApiContext();
const [html, setHtml] = useState('');
useLayoutEffect(() => {
if (!highlighter) return;
const themedHtml = highlighter.codeToHtml(code, {
lang,
defaultColor: false,
themes: { light: 'github-light', dark: 'github-dark' },
transformers: sharedTransformers,
});
setHtml(themedHtml);
}, [code, lang, highlighter]);
return (
<Base.CodeBlock className="my-0">
<Base.Pre {...props} dangerouslySetInnerHTML={{ __html: html }} />
</Base.CodeBlock>
export function CodeBlock({ code, lang = 'json' }: CodeBlockProps): ReactNode {
const { highlight } = useApiContext();
const [rendered, setRendered] = useState<ReactNode>(
<Base.Pre className="max-h-[288px]">{code}</Base.Pre>,
);
useEffect(() => {
void highlight(lang, code).then((res) => {
const output = toJsxRuntime(res, {
jsx,
jsxs,
development: false,
Fragment,
components: {
pre: (props) => (
<Base.Pre className="max-h-[288px]" {...props}>
{props.children}
</Base.Pre>
),
},
});
setRendered(output);
});
}, [code, highlight, lang]);
return <Base.CodeBlock className="my-0">{rendered}</Base.CodeBlock>;
}
+45 -33
View File
@@ -1,67 +1,79 @@
import { createContext, useContext, useEffect, useState } from 'react';
import type { HighlighterCore } from 'shiki/core';
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import type { RenderContext } from '@/types';
import { sharedTransformers } from '@/utils/shiki';
export interface ApiProviderProps {
/**
* Base URL for API requests
*/
defaultBaseUrl?: string;
shikiOptions: RenderContext['shikiOptions'];
children?: React.ReactNode;
children?: ReactNode;
}
interface ApiContextType {
baseUrl?: string;
setBaseUrl: (value: string) => void;
highlighter: HighlighterCore | null;
highlight: (
lang: string,
code: string,
) => ReturnType<(typeof import('shiki/bundle/web'))['codeToHast']>;
}
const ApiContext = createContext<ApiContextType>({
baseUrl: undefined,
setBaseUrl: () => undefined,
highlighter: null,
});
const ApiContext = createContext<ApiContextType | undefined>(undefined);
export function useApiContext(): ApiContextType {
return useContext(ApiContext);
const ctx = useContext(ApiContext);
if (!ctx) throw new Error('Component must be used under <ApiProvider />');
return ctx;
}
async function initHighlighter(): Promise<HighlighterCore> {
const { getSingletonHighlighter } = await import('shiki/bundle/web');
return getSingletonHighlighter({
themes: ['github-light', 'github-dark'],
langs: ['json'],
});
}
let highlighterInstance: HighlighterCore | undefined;
export function ApiProvider({
defaultBaseUrl,
shikiOptions,
children,
}: ApiProviderProps): React.ReactElement {
const [highlighter, setHighlighter] = useState<HighlighterCore | null>(null);
const [baseUrl, setBaseUrl] = useState(defaultBaseUrl);
useEffect(() => {
setBaseUrl((prev) => localStorage.getItem('apiBaseUrl') ?? prev);
if (highlighterInstance) {
setHighlighter(highlighterInstance);
} else {
void initHighlighter().then((res) => {
highlighterInstance = res;
setHighlighter(res);
});
}
}, []);
useEffect(() => {
useOnChange(baseUrl, () => {
if (baseUrl) localStorage.setItem('apiBaseUrl', baseUrl);
}, [baseUrl]);
});
return (
<ApiContext.Provider value={{ baseUrl, setBaseUrl, highlighter }}>
<ApiContext.Provider
value={useMemo(
() => ({
baseUrl,
setBaseUrl,
highlight: async (lang, code) => {
const { codeToHast } = await import('shiki/bundle/web');
return codeToHast(code, {
lang,
themes: { light: 'github-light', dark: 'github-dark' },
transformers: sharedTransformers,
defaultColor: false,
...shikiOptions,
});
},
}),
[baseUrl, shikiOptions],
)}
>
{children}
</ApiContext.Provider>
);
+1 -4
View File
@@ -330,10 +330,7 @@ function ResultDisplay({
</div>
<p className="text-sm text-fd-muted-foreground">{data.status}</p>
{data.data ? (
<CodeBlock
code={JSON.stringify(data.data, null, 2)}
className="max-h-[288px]"
/>
<CodeBlock code={JSON.stringify(data.data, null, 2)} />
) : null}
</div>
);
+2 -2
View File
@@ -93,7 +93,7 @@ export function DocsPage({
<AnchorProvider toc={toc} single={tocOptions.single}>
<div
id="nd-page"
className="h-fit w-full min-w-0 max-w-[var(--fd-page-width)] md:transition-[max-width]"
className="flex w-full min-w-0 max-w-[var(--fd-page-width)] flex-col md:transition-[max-width]"
style={
{
'--fd-page-width':
@@ -119,7 +119,7 @@ export function DocsPage({
)}
<article
className={cn(
'mx-auto flex size-full flex-col gap-6 px-4 pt-10 md:px-6 md:pt-12',
'mx-auto flex w-full flex-1 flex-col gap-6 px-4 pt-10 md:px-6 md:pt-12',
tocOptions.enabled ? 'max-w-[860px]' : 'max-w-[1120px]',
)}
>