mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-20 17:43:24 +00:00
Add Custom language option to OpenAPI code samples
OpenAPI code-sample blocks now show a language icon next to every option and add a "Custom language" entry that opens the assistant pre-filled (as a draft, not sent) with the current request staged as a reference and the prompt "Rewrite this in the following language: ", letting readers rewrite the request in any language. - react-openapi: new OpenAPICodeSampleAssistant context the host app fills in; the selector renders per-language icons and the Custom action item (OpenAPISelect gains an action/onAction concept so it fires without changing the selection). Adds getCodeSampleIcon to the render context and 3 translation keys across all locales. - gitbook: new setDraft controller method + inputDraft state so the chat input can be pre-filled without sending; OpenAPICodeSampleAIProvider bridges the GitBook assistant (logo, name, reference + draft action) into react-openapi, mounted once in SpaceLayout (covers site and embed); maps languages to icons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@gitbook/react-openapi": minor
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
OpenAPI code samples: add a "Custom" option to the language dropdown that opens the assistant pre-filled (as a draft) to rewrite the request in any language, and show a language icon next to every option.
|
||||
@@ -101,6 +101,12 @@ export type AIChatState = {
|
||||
* References staged on the next user message.
|
||||
*/
|
||||
references: AIChatReference[];
|
||||
|
||||
/**
|
||||
* Draft text to pre-fill into the chat input without sending it. Consumed by
|
||||
* the input on the next render and then reset to `null`.
|
||||
*/
|
||||
inputDraft: string | null;
|
||||
};
|
||||
|
||||
export type AIChatEvent =
|
||||
@@ -124,6 +130,8 @@ export type AIChatController = {
|
||||
close: () => void;
|
||||
/** Post a message to the session */
|
||||
postMessage: (input: { message: string }) => void;
|
||||
/** Pre-fill the chat input with a draft message, without sending it */
|
||||
setDraft: (value: string | null) => void;
|
||||
/** Clear the conversation */
|
||||
clear: () => void;
|
||||
/** Stage a reference on the next message */
|
||||
@@ -156,6 +164,7 @@ const globalState = zustand.create<AIChatState>(() => {
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
inputDraft: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -566,6 +575,7 @@ export function AIChatProvider(props: {
|
||||
error: false,
|
||||
initialQuery: null,
|
||||
references: [],
|
||||
inputDraft: null,
|
||||
}));
|
||||
|
||||
// Reset ask parameter to empty string (keeps chat open but clears content)
|
||||
@@ -615,6 +625,10 @@ export function AIChatProvider(props: {
|
||||
notify(eventsRef.current.get('focus'), {});
|
||||
}, []);
|
||||
|
||||
const onSetDraft = React.useCallback((value: string | null) => {
|
||||
globalState.setState((state) => ({ ...state, inputDraft: value }));
|
||||
}, []);
|
||||
|
||||
const onEvent = React.useCallback(
|
||||
<T extends AIChatEvent['type']>(
|
||||
event: T,
|
||||
@@ -640,6 +654,7 @@ export function AIChatProvider(props: {
|
||||
close: onClose,
|
||||
clear: onClear,
|
||||
postMessage: onPostMessage,
|
||||
setDraft: onSetDraft,
|
||||
addReference: onAddReference,
|
||||
removeReference: onRemoveReference,
|
||||
clearReferences: onClearReferences,
|
||||
@@ -651,6 +666,7 @@ export function AIChatProvider(props: {
|
||||
onClose,
|
||||
onClear,
|
||||
onPostMessage,
|
||||
onSetDraft,
|
||||
onAddReference,
|
||||
onRemoveReference,
|
||||
onClearReferences,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useAIChatController, useAIChatState } from '../AI/useAIChat';
|
||||
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||
@@ -23,6 +23,16 @@ export function AIChatInput(props: {
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Consume a draft requested via the controller (e.g. "rewrite this code sample"),
|
||||
// pre-filling the input without sending it.
|
||||
useEffect(() => {
|
||||
if (chat.inputDraft != null) {
|
||||
setValue(chat.inputDraft);
|
||||
chatController.setDraft(null);
|
||||
}
|
||||
}, [chat.inputDraft, chatController]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.opened && !disabled && !loading) {
|
||||
@@ -63,6 +73,8 @@ export function AIChatInput(props: {
|
||||
sizing="large"
|
||||
label="Assistant chat input"
|
||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
onSubmit={(val) => onSubmit(val as string)}
|
||||
submitButton={{
|
||||
size: 'small',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useAIChatController, useAIConfig } from '@/components/AI';
|
||||
import { AIChatIcon, getAIChatName } from '@/components/AIChat';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import {
|
||||
type OpenAPICodeSampleAssistant,
|
||||
OpenAPICodeSampleAssistantProvider,
|
||||
} from '@gitbook/react-openapi';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Bridge the GitBook AI assistant into the OpenAPI code sample selector, so it can
|
||||
* offer a "Custom" option that opens the assistant pre-filled to rewrite a sample.
|
||||
*
|
||||
* When the assistant is not enabled, the provider passes `null` and the option is hidden.
|
||||
*/
|
||||
export function OpenAPICodeSampleAIProvider(props: { children: React.ReactNode }) {
|
||||
const { children } = props;
|
||||
const config = useAIConfig();
|
||||
const language = useLanguage();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
const assistant = useMemo<OpenAPICodeSampleAssistant | null>(() => {
|
||||
if (config.aiMode !== CustomizationAIMode.Assistant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: config.assistantName ?? getAIChatName(language, config.trademark),
|
||||
icon: (
|
||||
<AIChatIcon
|
||||
state="default"
|
||||
trademark={config.trademark}
|
||||
className="size-4 shrink-0"
|
||||
/>
|
||||
),
|
||||
onRewrite: ({ id, code, syntax, label, prompt }) => {
|
||||
if (!code.trim()) {
|
||||
return;
|
||||
}
|
||||
chatController.addReference({
|
||||
type: 'code-block',
|
||||
id,
|
||||
label: label ?? 'Code',
|
||||
content: code,
|
||||
syntax,
|
||||
});
|
||||
chatController.setDraft(prompt);
|
||||
chatController.open();
|
||||
chatController.focus();
|
||||
},
|
||||
};
|
||||
}, [config.aiMode, config.assistantName, config.trademark, language, chatController]);
|
||||
|
||||
return (
|
||||
<OpenAPICodeSampleAssistantProvider value={assistant}>
|
||||
{children}
|
||||
</OpenAPICodeSampleAssistantProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSONDocument } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import { type OpenAPIContextInput, checkIsValidLocale } from '@gitbook/react-openapi';
|
||||
|
||||
import type { BlockProps } from '../Block';
|
||||
@@ -71,6 +71,9 @@ export function getOpenAPIContext(args: {
|
||||
blockStyle="max-w-full"
|
||||
/>
|
||||
),
|
||||
getCodeSampleIcon: (sample) => (
|
||||
<Icon icon={getCodeSampleIconName(sample)} className="size-4 shrink-0" />
|
||||
),
|
||||
renderHeading: (headingProps) => (
|
||||
<Heading
|
||||
document={props.document}
|
||||
@@ -101,3 +104,47 @@ export function getOpenAPIContext(args: {
|
||||
locale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the icon shown next to a code sample language in the selector.
|
||||
* Falls back to a generic code icon for unknown languages.
|
||||
*/
|
||||
function getCodeSampleIconName(sample: { id?: string; syntax: string; label: string }): IconName {
|
||||
const key = (sample.id ?? sample.syntax).toLowerCase();
|
||||
switch (key) {
|
||||
case 'http':
|
||||
return 'globe';
|
||||
case 'curl':
|
||||
case 'bash':
|
||||
case 'sh':
|
||||
case 'shell':
|
||||
case 'zsh':
|
||||
return 'square-terminal';
|
||||
case 'javascript':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
case 'node':
|
||||
return 'js';
|
||||
case 'python':
|
||||
case 'py':
|
||||
return 'python';
|
||||
case 'go':
|
||||
case 'golang':
|
||||
return 'golang';
|
||||
case 'rust':
|
||||
case 'rs':
|
||||
return 'rust';
|
||||
case 'php':
|
||||
return 'php';
|
||||
case 'java':
|
||||
return 'java';
|
||||
case 'swift':
|
||||
return 'swift';
|
||||
case 'json':
|
||||
return 'brackets-curly';
|
||||
default:
|
||||
return 'code';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,6 +436,11 @@
|
||||
@apply flex flex-row items-center;
|
||||
}
|
||||
|
||||
/* Single code sample (no dropdown): icon next to the language label. */
|
||||
.openapi-codesample-label {
|
||||
@apply flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.openapi-response-media-types-examples-footer-content {
|
||||
@apply flex flex-row items-center gap-2.5;
|
||||
}
|
||||
@@ -628,7 +633,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select > button > span.react-aria-SelectValue {
|
||||
@apply shrink truncate flex items-center;
|
||||
@apply shrink truncate flex items-center gap-1.5;
|
||||
}
|
||||
|
||||
.openapi-select > button > .react-aria-SelectValue [slot="description"] {
|
||||
@@ -657,7 +662,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select-item {
|
||||
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
|
||||
@apply text-sm flex items-center gap-2 cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
|
||||
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
|
||||
}
|
||||
|
||||
@@ -665,8 +670,13 @@ body:has(.openapi-select-popover) {
|
||||
@apply flex flex-col gap-1 justify-start items-start;
|
||||
}
|
||||
|
||||
/* The "Custom" AI rewrite option: assistant icon next to a stacked title + description. */
|
||||
.openapi-select-item-custom .openapi-select-item-text {
|
||||
@apply flex flex-col min-w-0 gap-0.5;
|
||||
}
|
||||
|
||||
.openapi-select-item [slot="description"] {
|
||||
@apply text-xs text-tint-subtle;
|
||||
@apply text-xs text-tint-subtle whitespace-normal;
|
||||
}
|
||||
|
||||
.openapi-select button .openapi-markdown,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat } from '../AIChat';
|
||||
import { AdaptiveVisitorContextProvider } from '../Adaptive';
|
||||
import { Announcement } from '../Announcement';
|
||||
import { OpenAPICodeSampleAIProvider } from '../DocumentView/OpenAPI/OpenAPICodeSampleAIProvider';
|
||||
import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider, VisitorProvider } from '../Insights';
|
||||
import { SearchContainer, getSearchBaseProps } from '../Search';
|
||||
@@ -91,7 +92,9 @@ export function SpaceLayoutServerContext(props: SpaceLayoutProps) {
|
||||
>
|
||||
<InsightsProvider enabled={withTracking} eventUrl={eventUrl.toString()}>
|
||||
<AIChatProvider renderMessageOptions={aiChatRenderMessageOptions}>
|
||||
{children}
|
||||
<OpenAPICodeSampleAIProvider>
|
||||
{children}
|
||||
</OpenAPICodeSampleAIProvider>
|
||||
</AIChatProvider>
|
||||
</InsightsProvider>
|
||||
</VisitorProvider>
|
||||
|
||||
@@ -165,11 +165,18 @@ function generateCodeSamples(props: {
|
||||
);
|
||||
|
||||
return codeSampleGenerators.map((generator) => {
|
||||
const icon = context.getCodeSampleIcon?.({
|
||||
id: generator.id,
|
||||
syntax: generator.syntax,
|
||||
label: generator.label,
|
||||
});
|
||||
if (mediaTypeRendererFactories.length > 0) {
|
||||
const renderers = mediaTypeRendererFactories.map((generate) => generate(generator));
|
||||
return {
|
||||
key: `default-${generator.id}`,
|
||||
label: generator.label,
|
||||
icon,
|
||||
syntax: generator.syntax,
|
||||
body: (
|
||||
<OpenAPIMediaTypeExamplesBody
|
||||
method={data.method}
|
||||
@@ -186,6 +193,8 @@ function generateCodeSamples(props: {
|
||||
return {
|
||||
key: `default-${generator.id}`,
|
||||
label: generator.label,
|
||||
icon,
|
||||
syntax: generator.syntax,
|
||||
body: context.renderCodeBlock({
|
||||
code: generator.generate({
|
||||
url: { origin: serverUrlOrigin, path },
|
||||
@@ -299,6 +308,8 @@ function getCustomCodeSamples(props: {
|
||||
let customCodeSamples: null | Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
syntax?: string;
|
||||
body: React.ReactNode;
|
||||
}> = null;
|
||||
|
||||
@@ -312,6 +323,11 @@ function getCustomCodeSamples(props: {
|
||||
.map((sample, index) => ({
|
||||
key: `custom-sample-${sample.lang}-${index}`,
|
||||
label: sample.label || sample.lang,
|
||||
icon: context.getCodeSampleIcon?.({
|
||||
syntax: sample.lang,
|
||||
label: sample.label || sample.lang,
|
||||
}),
|
||||
syntax: sample.lang,
|
||||
body: context.renderCodeBlock({
|
||||
code: sample.source,
|
||||
syntax: sample.lang,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* A request to rewrite a code sample with the assistant.
|
||||
*/
|
||||
export interface OpenAPICodeSampleRewriteInput {
|
||||
/** Stable identifier of the code sample, used to stage the reference. */
|
||||
id: string;
|
||||
/** Source code of the currently displayed code sample. */
|
||||
code: string;
|
||||
/** Syntax/language of the code sample (e.g. `bash`, `python`). */
|
||||
syntax?: string;
|
||||
/** Human-readable label of the code sample (e.g. `cURL`). */
|
||||
label?: string;
|
||||
/** Localized prompt to pre-fill in the assistant input as a draft. */
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability provided by the host app to let the assistant rewrite a code sample.
|
||||
* When present, the code sample selector exposes a "Custom" option.
|
||||
*/
|
||||
export interface OpenAPICodeSampleAssistant {
|
||||
/** Display name of the assistant (e.g. `GitBook Assistant`). */
|
||||
label: string;
|
||||
/** Logo/icon of the assistant, displayed next to the "Custom" option. */
|
||||
icon: React.ReactNode;
|
||||
/**
|
||||
* Open the assistant with the given code sample staged as a reference and the
|
||||
* prompt pre-filled as a draft (not sent).
|
||||
*/
|
||||
onRewrite: (input: OpenAPICodeSampleRewriteInput) => void;
|
||||
}
|
||||
|
||||
const OpenAPICodeSampleAssistantContext = createContext<OpenAPICodeSampleAssistant | null>(null);
|
||||
|
||||
/**
|
||||
* Provide the assistant capability to the code sample selector.
|
||||
* Pass `null` to disable the "Custom" rewrite option.
|
||||
*/
|
||||
export function OpenAPICodeSampleAssistantProvider(props: {
|
||||
value: OpenAPICodeSampleAssistant | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<OpenAPICodeSampleAssistantContext.Provider value={props.value}>
|
||||
{props.children}
|
||||
</OpenAPICodeSampleAssistantContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Access the assistant capability, or `null` when no assistant is available.
|
||||
*/
|
||||
export function useOpenAPICodeSampleAssistant() {
|
||||
return useContext(OpenAPICodeSampleAssistantContext);
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Fragment, useCallback, useRef } from 'react';
|
||||
import type { Key } from 'react-aria';
|
||||
import { Separator, Text } from 'react-aria-components';
|
||||
import { useStore } from 'zustand';
|
||||
import { useOpenAPICodeSampleAssistant } from './OpenAPICodeSampleAssistant';
|
||||
import { OpenAPIPath } from './OpenAPIPath';
|
||||
import { OpenAPISelect, OpenAPISelectItem } from './OpenAPISelect';
|
||||
import { StaticSection } from './StaticSection';
|
||||
import type { OpenAPIClientContext } from './context';
|
||||
import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
|
||||
import { tString } from './translate';
|
||||
import type { OpenAPIOperationData } from './types';
|
||||
|
||||
/**
|
||||
* Key of the synthetic "Custom" option that opens the assistant to rewrite the
|
||||
* code sample. Chosen to avoid colliding with generated (`default-*`) or custom
|
||||
* (`custom-sample-*`) sample keys.
|
||||
*/
|
||||
const CUSTOM_CODE_SAMPLE_KEY = 'gitbook-ai-rewrite';
|
||||
|
||||
function useCodeSampleState(initialKey: Key = 'default') {
|
||||
const store = useStore(getOrCreateStoreByKey('codesample', initialKey));
|
||||
return {
|
||||
@@ -19,36 +29,104 @@ function useCodeSampleState(initialKey: Key = 'default') {
|
||||
}
|
||||
|
||||
type CodeSampleItem = OpenAPISelectItem & {
|
||||
icon?: React.ReactNode;
|
||||
syntax?: string;
|
||||
body: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
function OpenAPICodeSampleHeader(props: {
|
||||
items: CodeSampleItem[];
|
||||
selected: CodeSampleItem;
|
||||
data: OpenAPIOperationData;
|
||||
selectIcon?: React.ReactNode;
|
||||
context: OpenAPIClientContext;
|
||||
getSelectedCode: () => string;
|
||||
}) {
|
||||
const { data, items, selectIcon, context } = props;
|
||||
const { data, items, selected, selectIcon, context, getSelectedCode } = props;
|
||||
const assistant = useOpenAPICodeSampleAssistant();
|
||||
|
||||
// When an assistant is available, append a "Custom" option that opens it
|
||||
// pre-filled to rewrite the currently displayed sample in any language.
|
||||
const customItem: CodeSampleItem | null = assistant
|
||||
? {
|
||||
key: CUSTOM_CODE_SAMPLE_KEY,
|
||||
label: tString(context.translation, 'code_sample_custom'),
|
||||
action: true,
|
||||
body: null,
|
||||
}
|
||||
: null;
|
||||
const allItems = customItem ? [...items, customItem] : items;
|
||||
|
||||
const onCustomRewrite = () => {
|
||||
if (!assistant) {
|
||||
return;
|
||||
}
|
||||
const code = getSelectedCode();
|
||||
if (!code.trim()) {
|
||||
return;
|
||||
}
|
||||
assistant.onRewrite({
|
||||
id: `${context.blockKey ?? 'openapi'}-${String(selected.key)}`,
|
||||
code,
|
||||
syntax: selected.syntax,
|
||||
label: typeof selected.label === 'string' ? selected.label : undefined,
|
||||
prompt: tString(context.translation, 'code_sample_rewrite_prompt'),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenAPIPath context={context} canCopy={false} withServer={false} data={data} />
|
||||
{items.length > 1 ? (
|
||||
{allItems.length > 1 ? (
|
||||
<OpenAPISelect
|
||||
icon={selectIcon}
|
||||
items={items}
|
||||
items={allItems}
|
||||
stateKey="codesample"
|
||||
placement="bottom end"
|
||||
onAction={onCustomRewrite}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<OpenAPISelectItem key={item.key} id={item.key} value={item}>
|
||||
{item.label}
|
||||
</OpenAPISelectItem>
|
||||
))}
|
||||
{allItems.map((item) =>
|
||||
item.key === CUSTOM_CODE_SAMPLE_KEY && assistant ? (
|
||||
<Fragment key={item.key}>
|
||||
<Separator className="border-tint-subtle border-t" />
|
||||
<OpenAPISelectItem
|
||||
id={item.key}
|
||||
value={item}
|
||||
textValue={tString(context.translation, 'code_sample_custom')}
|
||||
className="openapi-select-item-custom"
|
||||
>
|
||||
{assistant.icon}
|
||||
<span className="openapi-select-item-text">
|
||||
<Text slot="label">{item.label}</Text>
|
||||
<Text slot="description">
|
||||
{tString(
|
||||
context.translation,
|
||||
'code_sample_custom_description',
|
||||
assistant.label
|
||||
)}
|
||||
</Text>
|
||||
</span>
|
||||
</OpenAPISelectItem>
|
||||
</Fragment>
|
||||
) : (
|
||||
<OpenAPISelectItem
|
||||
key={item.key}
|
||||
id={item.key}
|
||||
value={item}
|
||||
textValue={typeof item.label === 'string' ? item.label : undefined}
|
||||
>
|
||||
{item.icon ?? null}
|
||||
<Text slot="label">{item.label}</Text>
|
||||
</OpenAPISelectItem>
|
||||
)
|
||||
)}
|
||||
</OpenAPISelect>
|
||||
) : items[0] ? (
|
||||
<span className="openapi-codesample-label">{items[0].label}</span>
|
||||
<span className="openapi-codesample-label">
|
||||
{items[0].icon ?? null}
|
||||
{items[0].label}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
@@ -66,6 +144,7 @@ export function OpenAPICodeSampleBody(props: {
|
||||
}
|
||||
|
||||
const state = useCodeSampleState(items[0]?.key);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = items.find((item) => item.key === state.key) || items[0];
|
||||
|
||||
@@ -81,14 +160,48 @@ export function OpenAPICodeSampleBody(props: {
|
||||
selectIcon={selectIcon}
|
||||
data={data}
|
||||
items={items}
|
||||
selected={selected}
|
||||
getSelectedCode={() => readPanelCodeText(panelRef.current)}
|
||||
/>
|
||||
}
|
||||
className="openapi-codesample"
|
||||
>
|
||||
<div id={selected.key as string} className="openapi-codesample-panel">
|
||||
<div ref={panelRef} id={selected.key as string} className="openapi-codesample-panel">
|
||||
{selected.body ? selected.body : null}
|
||||
{selected.footer ? selected.footer : null}
|
||||
</div>
|
||||
</StaticSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the plain code text of the code block currently displayed in a panel.
|
||||
* Mirrors the host code block rendering, where empty lines are represented with
|
||||
* a span of class "ew".
|
||||
*/
|
||||
function readPanelCodeText(panel: HTMLElement | null): string {
|
||||
const code = panel?.querySelector('code');
|
||||
if (!code) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let text = '';
|
||||
const iterate = (node: Node) => {
|
||||
if (node instanceof HTMLBRElement) {
|
||||
text += '\n';
|
||||
} else if (node instanceof HTMLSpanElement) {
|
||||
if (node.classList.contains('ew')) {
|
||||
text += '\n';
|
||||
} else {
|
||||
text += node.innerText;
|
||||
}
|
||||
} else if (node instanceof HTMLElement) {
|
||||
node.childNodes.forEach(iterate);
|
||||
} else {
|
||||
text += node.textContent ?? '';
|
||||
}
|
||||
};
|
||||
iterate(code);
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@ import { getOrCreateStoreByKey } from './getOrCreateStoreByKey';
|
||||
export type OpenAPISelectItem = {
|
||||
key: Key;
|
||||
label: string | React.ReactNode;
|
||||
/**
|
||||
* If `true`, selecting this item runs `onAction` instead of changing the selection,
|
||||
* leaving the current selection unchanged (e.g. an item that opens a dialog).
|
||||
*/
|
||||
action?: boolean;
|
||||
};
|
||||
|
||||
interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectProps<T>, 'children'> {
|
||||
@@ -31,6 +36,10 @@ interface OpenAPISelectProps<T extends OpenAPISelectItem> extends Omit<SelectPro
|
||||
* Icon to display in the select button.
|
||||
*/
|
||||
icon?: React.ReactNode | null;
|
||||
/**
|
||||
* Called when an item flagged with `action` is selected. The selection is not changed.
|
||||
*/
|
||||
onAction?: (key: Key) => void;
|
||||
}
|
||||
|
||||
export function useSelectState(stateKey = 'select-state', initialKey: Key = 'default') {
|
||||
@@ -42,8 +51,19 @@ export function useSelectState(stateKey = 'select-state', initialKey: Key = 'def
|
||||
}
|
||||
|
||||
export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectProps<T>) {
|
||||
const { icon, items, children, className, placement, stateKey, value, onChange, defaultValue } =
|
||||
props;
|
||||
const {
|
||||
icon,
|
||||
items,
|
||||
children,
|
||||
className,
|
||||
placement,
|
||||
stateKey,
|
||||
value,
|
||||
onChange,
|
||||
defaultValue,
|
||||
onAction,
|
||||
...selectProps
|
||||
} = props;
|
||||
|
||||
const state = useSelectState(stateKey, defaultValue ?? items[0]?.key);
|
||||
|
||||
@@ -52,9 +72,14 @@ export function OpenAPISelect<T extends OpenAPISelectItem>(props: OpenAPISelectP
|
||||
return (
|
||||
<Select
|
||||
aria-label="OpenAPI Select"
|
||||
{...props}
|
||||
{...selectProps}
|
||||
value={value ?? selected?.key}
|
||||
onChange={(key) => {
|
||||
// Action items trigger a side effect without changing the selection.
|
||||
if (key !== null && items.find((item) => item.key === key)?.action) {
|
||||
onAction?.(key);
|
||||
return;
|
||||
}
|
||||
onChange?.(key);
|
||||
state.setKey(key);
|
||||
}}
|
||||
|
||||
@@ -72,6 +72,19 @@ export interface OpenAPIContext
|
||||
*/
|
||||
renderDocument: (props: { document: object }) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Render the icon associated with a code sample language, displayed in the
|
||||
* code sample language selector. Optional: when omitted, options render without an icon.
|
||||
*/
|
||||
getCodeSampleIcon?: (sample: {
|
||||
/** Identifier of the built-in generator (e.g. `curl`, `javascript`), if any. */
|
||||
id?: string;
|
||||
/** Syntax/language of the sample (e.g. `bash`, `python`). */
|
||||
syntax: string;
|
||||
/** Human-readable label of the sample (e.g. `cURL`). */
|
||||
label: string;
|
||||
}) => React.ReactNode;
|
||||
|
||||
/**
|
||||
* Public specification URL, used by Scalar's "Test it" modal.
|
||||
* When null, the "Test it" button is hidden.
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './schemas';
|
||||
export * from './OpenAPIOperation';
|
||||
export * from './OpenAPIWebhook';
|
||||
export * from './OpenAPIOperationContext';
|
||||
export * from './OpenAPICodeSampleAssistant';
|
||||
export * from './OpenAPIPrefillContextProvider';
|
||||
export * from './resolveOpenAPIOperation';
|
||||
export * from './resolveOpenAPIWebhook';
|
||||
|
||||
@@ -43,4 +43,7 @@ export const de = {
|
||||
or: 'oder',
|
||||
and: 'und',
|
||||
possible_values: 'Mögliche Werte',
|
||||
code_sample_custom: 'Benutzerdefinierte Sprache',
|
||||
code_sample_custom_description: 'Mit ${1} umschreiben',
|
||||
code_sample_rewrite_prompt: 'Schreibe dies in die folgende Sprache um: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const en = {
|
||||
properties: 'Properties',
|
||||
or: 'or',
|
||||
and: 'and',
|
||||
code_sample_custom: 'Custom language',
|
||||
code_sample_custom_description: 'Rewrite with ${1}',
|
||||
code_sample_rewrite_prompt: 'Rewrite this in the following language: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const es = {
|
||||
or: 'o',
|
||||
and: 'y',
|
||||
possible_values: 'Valores posibles',
|
||||
code_sample_custom: 'Lenguaje personalizado',
|
||||
code_sample_custom_description: 'Reescribir con ${1}',
|
||||
code_sample_rewrite_prompt: 'Reescribe esto en el siguiente lenguaje: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const fr = {
|
||||
or: 'ou',
|
||||
and: 'et',
|
||||
possible_values: 'Valeurs possibles',
|
||||
code_sample_custom: 'Langage personnalisé',
|
||||
code_sample_custom_description: 'Réécrire avec ${1}',
|
||||
code_sample_rewrite_prompt: 'Réécrivez ceci dans le langage suivant : ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const ja = {
|
||||
or: 'または',
|
||||
and: 'および',
|
||||
possible_values: '可能な値',
|
||||
code_sample_custom: 'カスタム言語',
|
||||
code_sample_custom_description: '${1} で書き換える',
|
||||
code_sample_rewrite_prompt: '次の言語に書き換えてください: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const nl = {
|
||||
or: 'of',
|
||||
and: 'en',
|
||||
possible_values: 'Mogelijke waarden',
|
||||
code_sample_custom: 'Aangepaste taal',
|
||||
code_sample_custom_description: 'Herschrijven met ${1}',
|
||||
code_sample_rewrite_prompt: 'Herschrijf dit in de volgende taal: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const no = {
|
||||
or: 'eller',
|
||||
and: 'og',
|
||||
possible_values: 'Mulige verdier',
|
||||
code_sample_custom: 'Egendefinert språk',
|
||||
code_sample_custom_description: 'Skriv om med ${1}',
|
||||
code_sample_rewrite_prompt: 'Skriv om dette til følgende språk: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const pt_br = {
|
||||
or: 'ou',
|
||||
and: 'e',
|
||||
possible_values: 'Valores possíveis',
|
||||
code_sample_custom: 'Linguagem personalizada',
|
||||
code_sample_custom_description: 'Reescrever com ${1}',
|
||||
code_sample_rewrite_prompt: 'Reescreva isto na seguinte linguagem: ',
|
||||
};
|
||||
|
||||
@@ -43,4 +43,7 @@ export const zh = {
|
||||
or: '或',
|
||||
and: '和',
|
||||
possible_values: '可能的值',
|
||||
code_sample_custom: '自定义语言',
|
||||
code_sample_custom_description: '使用 ${1} 重写',
|
||||
code_sample_rewrite_prompt: '用以下语言重写: ',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user