mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
OpenAPI: Implement i18n
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-openapi': minor
|
||||
---
|
||||
|
||||
Implement i18n
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
'@fumadocs/base-ui': minor
|
||||
---
|
||||
|
||||
Improved `defineI18nUI()` usage: allow language translations to be defined at root config.
|
||||
@@ -98,6 +98,31 @@ paths:
|
||||
planter.list({ unwatered: true });
|
||||
```
|
||||
|
||||
### Internationalization
|
||||
|
||||
Assuming you have configured [Internationalization](/docs/internationalization) at UI level:
|
||||
|
||||
```tsx title="layout.shared.tsx"
|
||||
import { defineI18n } from 'fumadocs-core/i18n';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import { defineI18nOpenAPI } from 'fumadocs-openapi/i18n';
|
||||
|
||||
const i18n = defineI18n({
|
||||
languages: ['en', 'cn'],
|
||||
defaultLanguage: 'en',
|
||||
});
|
||||
|
||||
export let i18nUI = defineI18nUI(i18n, {
|
||||
cn: { displayName: 'Chinese' },
|
||||
});
|
||||
|
||||
i18nUI = defineI18nOpenAPI(i18nUI, {
|
||||
cn: {
|
||||
titleRequestBody: '請求正文',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Media Adapters
|
||||
|
||||
You can create a media adapter to support other media types in API pages, a media adapter implements:
|
||||
|
||||
@@ -45,16 +45,14 @@ import { i18n } from '@/lib/i18n';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
// [!code ++:11]
|
||||
// [!code ++:9]
|
||||
export const i18nUI = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -71,7 +69,6 @@ Pass translations to `<RootProvider />`:
|
||||
|
||||
```tsx title="app/[lang]/layout.tsx"
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import { i18nUI } from '@/lib/layout.shared';
|
||||
|
||||
export default async function RootLayout({
|
||||
|
||||
@@ -70,16 +70,14 @@ import { i18n } from '@/lib/i18n';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
// [!code ++:11]
|
||||
// [!code ++:9]
|
||||
export const i18nUI = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -29,16 +29,14 @@ import { i18n } from '@/lib/i18n';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
// [!code ++:11]
|
||||
// [!code ++:9]
|
||||
export const i18nUI = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: '搜尋文檔',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,20 +3,18 @@ import { i18n } from '@/lib/i18n';
|
||||
import { defineI18nUI } from 'fumadocs-ui/i18n';
|
||||
|
||||
export const i18nUI = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
toc: '目錄',
|
||||
search: '搜尋文檔',
|
||||
lastUpdate: '最後更新於',
|
||||
searchNoResult: '沒有結果',
|
||||
previousPage: '上一頁',
|
||||
nextPage: '下一頁',
|
||||
chooseLanguage: '選擇語言',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
toc: '目錄',
|
||||
search: '搜尋文檔',
|
||||
lastUpdate: '最後更新於',
|
||||
searchNoResult: '沒有結果',
|
||||
previousPage: '上一頁',
|
||||
nextPage: '下一頁',
|
||||
chooseLanguage: '選擇語言',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -33,14 +33,12 @@ function RootComponent() {
|
||||
}
|
||||
|
||||
const { provider } = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: 'Translated Content',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: 'Translated Content',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import { i18n } from '@/lib/i18n';
|
||||
import { useRouter } from 'waku/router/client';
|
||||
|
||||
const { provider } = defineI18nUI(i18n, {
|
||||
translations: {
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: 'Search (Translated)',
|
||||
},
|
||||
en: {
|
||||
displayName: 'English',
|
||||
},
|
||||
cn: {
|
||||
displayName: 'Chinese',
|
||||
search: 'Search (Translated)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,15 +17,19 @@ export interface Translations {
|
||||
editOnGithub: string;
|
||||
}
|
||||
|
||||
export interface LocaleItem {
|
||||
export type TranslationsOption = {
|
||||
[key: string]: string | TranslationsOption;
|
||||
};
|
||||
|
||||
interface LocaleItem {
|
||||
name: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface I18nContextType {
|
||||
text: Translations & TranslationsOption;
|
||||
locale?: string;
|
||||
onChange?: (v: string) => void;
|
||||
text: Translations;
|
||||
locales?: LocaleItem[];
|
||||
}
|
||||
|
||||
@@ -43,12 +47,11 @@ export const defaultTranslations: Translations = {
|
||||
};
|
||||
|
||||
const I18nContext = createContext<I18nContextType>({
|
||||
text: defaultTranslations,
|
||||
text: { ...defaultTranslations },
|
||||
});
|
||||
|
||||
export function I18nLabel(props: { label: keyof Translations }): string {
|
||||
const { text } = useI18n();
|
||||
|
||||
const text = useI18n().text;
|
||||
return text[props.label];
|
||||
}
|
||||
|
||||
@@ -60,7 +63,7 @@ export interface I18nProviderProps {
|
||||
/**
|
||||
* Current locale
|
||||
*/
|
||||
locale: string;
|
||||
locale?: string;
|
||||
|
||||
/**
|
||||
* Handle changes to the locale, redirect user when not specified.
|
||||
@@ -70,7 +73,7 @@ export interface I18nProviderProps {
|
||||
/**
|
||||
* Translations of current locale
|
||||
*/
|
||||
translations?: Partial<Translations>;
|
||||
translations?: TranslationsOption;
|
||||
|
||||
/**
|
||||
* Available languages
|
||||
|
||||
@@ -7,18 +7,25 @@ export interface I18nUIConfig<Languages extends string> extends I18nConfig<Langu
|
||||
/**
|
||||
* get i18n config for Fumadocs UI `<RootProvider i18n={config} />`.
|
||||
*/
|
||||
provider: (locale?: string) => I18nProviderProps;
|
||||
provider: (locale?: Languages | (string & {})) => I18nProviderProps;
|
||||
}
|
||||
|
||||
type TranslationsConfig<Languages extends string> = {
|
||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||
};
|
||||
|
||||
export function defineI18nUI<Languages extends string>(
|
||||
config: I18nConfig<Languages>,
|
||||
options: {
|
||||
translations: {
|
||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||
};
|
||||
},
|
||||
options:
|
||||
| {
|
||||
/**
|
||||
* @deprecated you can directly define the translations in outer scope (the parent object of `translations`)
|
||||
*/
|
||||
translations: TranslationsConfig<Languages>;
|
||||
}
|
||||
| TranslationsConfig<Languages> = {},
|
||||
): I18nUIConfig<Languages> {
|
||||
const { translations } = options;
|
||||
const translations = 'translations' in options ? options.translations : options;
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./i18n": "./dist/i18n.js",
|
||||
"./playground": "./dist/playground/index.js",
|
||||
"./playground/client": "./dist/playground/client.js",
|
||||
"./requests/generators": "./dist/requests/generators/index.js",
|
||||
@@ -49,6 +50,7 @@
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/deepmerge": "^3.2.1",
|
||||
"@fumari/json-schema-ts": "^0.0.2",
|
||||
"@fumari/stf": "workspace:*",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { I18nUIConfig } from 'fumadocs-ui/i18n';
|
||||
import { deepmerge } from '@fastify/deepmerge';
|
||||
import type { TranslationsOption } from 'fumadocs-ui/contexts/i18n';
|
||||
|
||||
export const defaultTranslations = {
|
||||
// General
|
||||
loading: 'loading...',
|
||||
empty: 'Empty',
|
||||
copy: 'Copy',
|
||||
send: 'Send',
|
||||
authorization: 'Authorization',
|
||||
cookies: 'Cookies',
|
||||
query: 'Query',
|
||||
path: 'Path',
|
||||
header: 'Header',
|
||||
body: 'Body',
|
||||
deprecated: 'Deprecated',
|
||||
submit: 'Submit',
|
||||
unsupported: 'Unsupported',
|
||||
close: 'Close',
|
||||
inputPlaceholder: 'Enter value',
|
||||
|
||||
// Request tabs
|
||||
titleRequestTabs: 'Example Requests',
|
||||
requestTabNameDefault: 'Default',
|
||||
queryParameters: 'Query Parameters',
|
||||
pathParameters: 'Path Parameters',
|
||||
headerParameters: 'Header Parameters',
|
||||
cookieParameters: 'Cookie Parameters',
|
||||
|
||||
// Operation sections
|
||||
titleRequestBody: 'Request Body',
|
||||
titleResponseBody: 'Response Body',
|
||||
titleCallbacks: 'Callbacks',
|
||||
|
||||
// Auth scheme
|
||||
authBasicTokenExample: 'Basic <token>',
|
||||
authBearerTokenExample: 'Bearer <token>',
|
||||
openIdConnect: 'OpenID Connect',
|
||||
authTokenIn: 'In',
|
||||
authScope: 'Scope',
|
||||
|
||||
// TypeScript panel
|
||||
typeScriptDefinitions: 'TypeScript Definitions',
|
||||
useTypeInTypeScript: 'Use the {name} type in TypeScript.',
|
||||
|
||||
// Schema info tags
|
||||
schemaDefault: 'Default',
|
||||
schemaMatch: 'Match',
|
||||
schemaFormat: 'Format',
|
||||
schemaMultipleOf: 'Multiple Of',
|
||||
schemaRange: 'Range',
|
||||
schemaLength: 'Length',
|
||||
schemaProperties: 'Properties',
|
||||
schemaItems: 'Items',
|
||||
schemaValueIn: 'Value in',
|
||||
schemaExample: 'Example',
|
||||
|
||||
// Response tabs
|
||||
responseTabName: 'Example {key}',
|
||||
responseTabNameDefault: 'Example',
|
||||
|
||||
// Playground
|
||||
closeJsonEditor: 'Close JSON Editor',
|
||||
openJsonEditor: 'Open JSON Editor',
|
||||
accessToken: 'Access Token',
|
||||
authorize: 'Authorize',
|
||||
openIdUnsupported:
|
||||
'OpenID Connect is not supported at the moment, you can still set an access token here.',
|
||||
|
||||
// Status info
|
||||
statusBadRequest: 'Bad Request',
|
||||
statusUnauthorized: 'Unauthorized',
|
||||
statusForbidden: 'Forbidden',
|
||||
statusNotFound: 'Not Found',
|
||||
statusInternalServerError: 'Internal Server Error',
|
||||
statusSuccessful: 'Successful',
|
||||
statusError: 'Error',
|
||||
statusNoDescription: 'No Description',
|
||||
|
||||
// OAuth dialog
|
||||
obtainAccessToken: 'Obtain the access token for API.',
|
||||
resourceOwnerPassword: 'Resource Owner Password Flow',
|
||||
resourceOwnerPasswordDesc: 'Authenticate using username and password.',
|
||||
clientCredentials: 'Client Credentials',
|
||||
clientCredentialsDesc: 'Intended for the server-to-server authentication.',
|
||||
authorizationCode: 'Authorization code',
|
||||
authorizationCodeDesc: 'Authenticate with 3rd party services',
|
||||
implicit: 'Implicit',
|
||||
implicitDesc: 'Retrieve the access token directly.',
|
||||
deviceAuthorization: 'Device Authorization',
|
||||
deviceAuthorizationDesc: 'Authenticate with device.',
|
||||
clientId: 'Client ID',
|
||||
clientIdHint: 'The client ID of your OAuth application.',
|
||||
clientSecret: 'Client Secret',
|
||||
clientSecretHint: 'The client secret of your OAuth application.',
|
||||
usernameField: 'Username',
|
||||
passwordField: 'Password',
|
||||
fetchingToken: 'Fetching token...',
|
||||
|
||||
// Server select
|
||||
serverUrl: 'Server URL',
|
||||
serverUrlDescription: 'The base URL of your API endpoint.',
|
||||
serverUrlFieldPlaceholder: 'Enter Value',
|
||||
|
||||
// Schema UI
|
||||
schemaShowArray: 'Array Item',
|
||||
schemaHideArray: 'Array Item',
|
||||
schemaFilterPropertiesPlaceholder: 'Filter Properties',
|
||||
schemaFilterPropertiesEmpty: 'No property matching',
|
||||
|
||||
// Inputs (playground)
|
||||
playgroundShowProperty: 'Show Property',
|
||||
playgroundPropertyPlaceholder: 'Enter Property Name',
|
||||
playgroundNewProperty: 'New',
|
||||
playgroundNewItem: 'New Item',
|
||||
playgroundRemoveItem: 'Remove Item',
|
||||
playgroundSelectPlaceholder: 'Select',
|
||||
playgroundSelected: 'Selected',
|
||||
playgroundInputUpload: 'Upload',
|
||||
playgroundInputUnset: 'Unset',
|
||||
};
|
||||
|
||||
export type Translations = typeof defaultTranslations;
|
||||
|
||||
export function defineI18nOpenAPI<Languages extends string>(
|
||||
config: I18nUIConfig<Languages>,
|
||||
translations: Partial<Record<NoInfer<Languages>, Partial<Translations>>>,
|
||||
): I18nUIConfig<Languages> {
|
||||
const dm = deepmerge();
|
||||
return {
|
||||
...config,
|
||||
provider(locale = config.defaultLanguage) {
|
||||
const out = config.provider(locale);
|
||||
const data = translations[locale as Languages];
|
||||
if (data) {
|
||||
out.translations ??= {};
|
||||
out.translations.openapi = dm(defaultTranslations, data) as TranslationsOption;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import { objectGet, objectSet, stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
import { FieldInput, FieldSet, JsonInput, ObjectInput } from './components/inputs';
|
||||
import type { ParameterObject } from '@/types';
|
||||
import { ClientCodeBlock } from '@/ui/components/codeblock';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
export interface FormValues extends Record<string, unknown> {
|
||||
path: Record<string, unknown>;
|
||||
@@ -139,6 +140,7 @@ export default function PlaygroundClient({
|
||||
readOnly,
|
||||
...rest
|
||||
}: PlaygroundClientProps) {
|
||||
const t = useTranslations();
|
||||
const { example: exampleId, examples, setExampleData } = useExampleRequests();
|
||||
const storageKeys = useStorageKey();
|
||||
const {
|
||||
@@ -268,7 +270,7 @@ export default function PlaygroundClient({
|
||||
className={cn(buttonVariants({ color: 'primary', size: 'sm' }), 'w-14 py-1.5')}
|
||||
disabled={testQuery.isLoading}
|
||||
>
|
||||
{testQuery.isLoading ? <LoaderCircle className="size-4 animate-spin" /> : 'Send'}
|
||||
{testQuery.isLoading ? <LoaderCircle className="size-4 animate-spin" /> : t.send}
|
||||
</button>
|
||||
</div>
|
||||
{testQuery.data ? <ResultDisplay data={testQuery.data} reset={testQuery.reset} /> : null}
|
||||
@@ -304,9 +306,10 @@ function SecurityTabs({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const engine = useDataEngine();
|
||||
const t = useTranslations();
|
||||
|
||||
const result = (
|
||||
<CollapsiblePanel title="Authorization">
|
||||
<CollapsiblePanel title={t.authorization}>
|
||||
<Select value={securityId.toString()} onValueChange={(v) => setSecurityId(Number(v))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -367,6 +370,7 @@ const ParamTypes = ['path', 'header', 'cookie', 'query'] as const;
|
||||
|
||||
function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parameters' | 'body'>) {
|
||||
const { renderParameterField, renderBodyField } = useApiContext().client.playground ?? {};
|
||||
const t = useTranslations();
|
||||
const panels = useMemo(() => {
|
||||
return ParamTypes.map((type) => {
|
||||
const items = parameters.filter((v) => v.in === type);
|
||||
@@ -377,10 +381,10 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
||||
key={type}
|
||||
title={
|
||||
{
|
||||
header: 'Header',
|
||||
cookie: 'Cookies',
|
||||
query: 'Query',
|
||||
path: 'Path',
|
||||
header: t.header,
|
||||
cookie: t.cookies,
|
||||
query: t.query,
|
||||
path: t.path,
|
||||
}[type]
|
||||
}
|
||||
>
|
||||
@@ -410,13 +414,13 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
});
|
||||
}, [parameters, renderParameterField]);
|
||||
}, [parameters, renderParameterField, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{panels}
|
||||
{body && (
|
||||
<CollapsiblePanel title="Body">
|
||||
<CollapsiblePanel title={t.body}>
|
||||
{renderBodyField ? renderBodyField('body', body) : <BodyInput field={body.schema} />}
|
||||
</CollapsiblePanel>
|
||||
)}
|
||||
@@ -427,6 +431,7 @@ function FormBody({ parameters = [], body }: Pick<PlaygroundClientProps, 'parame
|
||||
function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
const field = useResolvedSchema(_field);
|
||||
const [isJson, setIsJson] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
if (field.format === 'binary') return <FieldSet field={field} fieldName={['body']} isRequired />;
|
||||
|
||||
@@ -444,7 +449,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
onClick={() => setIsJson(false)}
|
||||
type="button"
|
||||
>
|
||||
Close JSON Editor
|
||||
{t.closeJsonEditor}
|
||||
</button>
|
||||
<JsonInput fieldName={['body']} />
|
||||
</>
|
||||
@@ -468,7 +473,7 @@ function BodyInput({ field: _field }: { field: ParsedSchema }) {
|
||||
)}
|
||||
onClick={() => setIsJson(true)}
|
||||
>
|
||||
Open JSON Editor
|
||||
{t.openJsonEditor}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
@@ -490,6 +495,7 @@ function useAuthInputs(
|
||||
transform?: (fields: AuthField[]) => AuthField[],
|
||||
) {
|
||||
const storageKeys = useStorageKey();
|
||||
const t = useTranslations();
|
||||
const inputs = useMemo(() => {
|
||||
const result: AuthField[] = [];
|
||||
if (!securities) return result;
|
||||
@@ -540,7 +546,7 @@ function useAuthInputs(
|
||||
children: (
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<label htmlFor={stringifyFieldKey(fieldName)} className={cn(labelVariants())}>
|
||||
Access Token
|
||||
{t.accessToken}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<FieldInput
|
||||
@@ -561,7 +567,7 @@ function useAuthInputs(
|
||||
}),
|
||||
)}
|
||||
>
|
||||
Authorize
|
||||
{t.authorize}
|
||||
</OauthDialogTrigger>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -576,7 +582,7 @@ function useAuthInputs(
|
||||
defaultValue: 'Bearer ',
|
||||
children: (
|
||||
<FieldSet
|
||||
name="Authorization (header)"
|
||||
name={`${t.authorization} (${t.header})`}
|
||||
fieldName={fieldName}
|
||||
isRequired
|
||||
field={{
|
||||
@@ -613,17 +619,14 @@ function useAuthInputs(
|
||||
children: (
|
||||
<>
|
||||
<FieldSet
|
||||
name="Authorization (header)"
|
||||
name={`${t.authorization} (${t.header})`}
|
||||
isRequired
|
||||
fieldName={fieldName}
|
||||
field={{
|
||||
type: 'string',
|
||||
}}
|
||||
/>
|
||||
<p className="text-fd-muted-foreground text-xs">
|
||||
OpenID Connect is not supported at the moment, you can still set an access token
|
||||
here.
|
||||
</p>
|
||||
<p className="text-fd-muted-foreground text-xs">{t.openIdUnsupported}</p>
|
||||
</>
|
||||
),
|
||||
});
|
||||
@@ -631,7 +634,7 @@ function useAuthInputs(
|
||||
}
|
||||
|
||||
return transform ? transform(result) : result;
|
||||
}, [securities, transform]);
|
||||
}, [securities, transform, t]);
|
||||
|
||||
const mapInputs = (values: FormValues) => {
|
||||
const cloned = structuredClone(values);
|
||||
@@ -695,7 +698,8 @@ function Route({ route, ...props }: ComponentProps<'div'> & { route: string }) {
|
||||
}
|
||||
|
||||
function DefaultResultDisplay({ data, reset }: { data: FetchResult; reset: () => void }) {
|
||||
const statusInfo = useMemo(() => getStatusInfo(data.status), [data.status]);
|
||||
const t = useTranslations();
|
||||
const statusInfo = useMemo(() => getStatusInfo(data.status, t), [data.status, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 mt-2 px-3 py-2 border-y bg-fd-secondary text-fd-secondary-foreground">
|
||||
@@ -709,7 +713,7 @@ function DefaultResultDisplay({ data, reset }: { data: FetchResult; reset: () =>
|
||||
className={cn(buttonVariants({ size: 'sm', variant: 'outline' }))}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Close
|
||||
{t.close}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-fd-muted-foreground">{data.status}</p>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import type { ParsedSchema } from '@/utils/schema';
|
||||
import { stringifyFieldKey } from '@fumari/stf/lib/utils';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
const fieldLabelVariants = cva('w-full inline-flex items-center gap-0.5');
|
||||
|
||||
@@ -62,6 +63,7 @@ export function ObjectInput({
|
||||
});
|
||||
|
||||
const hiddenProperties = isLazy ? schemaPropKeys.filter((key) => !_objectKeys.includes(key)) : [];
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -74,7 +76,7 @@ export function ObjectInput({
|
||||
{isLazy && hiddenProperties.length > 0 && (
|
||||
<Select value="" onValueChange={onAppend}>
|
||||
<SelectTrigger className="col-span-full">
|
||||
<SelectValue placeholder="Show Property" />
|
||||
<SelectValue placeholder={t.playgroundShowProperty} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hiddenProperties.map((key) => (
|
||||
@@ -92,7 +94,7 @@ export function ObjectInput({
|
||||
toolbar = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove Item"
|
||||
aria-label={t.playgroundRemoveItem}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'outline',
|
||||
@@ -123,7 +125,7 @@ export function ObjectInput({
|
||||
<div className="flex gap-2 order-last col-span-full">
|
||||
<Input
|
||||
value={nextName}
|
||||
placeholder="Enter Property Name"
|
||||
placeholder={t.playgroundPropertyPlaceholder}
|
||||
onChange={(e) => setNextName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -141,7 +143,7 @@ export function ObjectInput({
|
||||
setNextName('');
|
||||
}}
|
||||
>
|
||||
New
|
||||
{t.playgroundNewProperty}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -186,6 +188,7 @@ export function FieldInput({
|
||||
}) {
|
||||
const [value, setValue] = useFieldValue(fieldName);
|
||||
const id = stringifyFieldKey(fieldName);
|
||||
const t = useTranslations();
|
||||
if (field.type === 'null') return;
|
||||
|
||||
if (field.type === 'string' && field.format === 'binary') {
|
||||
@@ -202,11 +205,11 @@ export function FieldInput({
|
||||
>
|
||||
{value instanceof File ? (
|
||||
<>
|
||||
<span className="text-fd-muted-foreground text-xs">Selected</span>
|
||||
<span className="text-fd-muted-foreground text-xs">{t.playgroundSelected}</span>
|
||||
<span className="truncate w-0 flex-1 text-end">{value.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-fd-muted-foreground">Upload</span>
|
||||
<span className="text-fd-muted-foreground">{t.playgroundInputUpload}</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
@@ -232,7 +235,7 @@ export function FieldInput({
|
||||
onValueChange={(v) => setValue(field.enum![Number(v)])}
|
||||
>
|
||||
<SelectTrigger id={id} {...props}>
|
||||
<SelectValue placeholder="Select" />
|
||||
<SelectValue placeholder={t.playgroundSelectPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enum.map((item, i) => (
|
||||
@@ -240,7 +243,7 @@ export function FieldInput({
|
||||
{typeof item === 'string' ? item : JSON.stringify(item, null, 2)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!isRequired && <SelectItem value="-1">Unset</SelectItem>}
|
||||
{!isRequired && <SelectItem value="-1">{t.playgroundInputUnset}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -258,7 +261,7 @@ export function FieldInput({
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
{!isRequired && <SelectItem value="undefined">Unset</SelectItem>}
|
||||
{!isRequired && <SelectItem value="undefined">{t.playgroundInputUnset}</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -268,7 +271,7 @@ export function FieldInput({
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
placeholder="Enter value"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type={isNumber ? 'number' : 'text'}
|
||||
step={field.type === 'integer' ? 1 : undefined}
|
||||
value={String(value ?? '')}
|
||||
@@ -521,6 +524,7 @@ function ArrayInput({
|
||||
const name = fieldName.at(-1) ?? '';
|
||||
const { generateDefault } = useSchemaUtils();
|
||||
const { items, insertItem, removeItem } = useArray(fieldName);
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div {...props} className={cn('flex flex-col gap-2', props.className)}>
|
||||
@@ -538,7 +542,7 @@ function ArrayInput({
|
||||
toolbar={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove Item"
|
||||
aria-label={t.playgroundRemoveItem}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
color: 'outline',
|
||||
@@ -566,7 +570,7 @@ function ArrayInput({
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New Item
|
||||
{t.playgroundNewItem}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Input, labelVariants } from '@/ui/components/input';
|
||||
import { useQuery } from '@/utils/use-query';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/ui/components/select';
|
||||
import type { OAuth2SecurityScheme } from '@/types';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
type FlowType = keyof NonNullable<OAuth2SecurityScheme['flows']>;
|
||||
|
||||
@@ -51,33 +52,11 @@ interface ImplicitState {
|
||||
client_id: string;
|
||||
}
|
||||
|
||||
const FlowTypes = {
|
||||
password: {
|
||||
name: 'Resource Owner Password Flow',
|
||||
description: 'Authenticate using username and password.',
|
||||
supported: true,
|
||||
},
|
||||
clientCredentials: {
|
||||
name: 'Client Credentials',
|
||||
description: 'Intended for the server-to-server authentication.',
|
||||
supported: true,
|
||||
},
|
||||
authorizationCode: {
|
||||
name: 'Authorization code',
|
||||
description: 'Authenticate with 3rd party services',
|
||||
supported: true,
|
||||
},
|
||||
implicit: {
|
||||
name: 'Implicit',
|
||||
description: 'Retrieve the access token directly.',
|
||||
supported: true,
|
||||
},
|
||||
deviceAuthorization: {
|
||||
name: 'Device Authorization',
|
||||
description: 'Authenticate with device.',
|
||||
supported: false,
|
||||
},
|
||||
} as const;
|
||||
interface FlowInfo {
|
||||
name: ReactNode;
|
||||
description: ReactNode;
|
||||
supported: boolean;
|
||||
}
|
||||
|
||||
export function OauthDialog({
|
||||
scheme,
|
||||
@@ -90,7 +69,38 @@ export function OauthDialog({
|
||||
const [type, setType] = useState(() => {
|
||||
return Object.keys(scheme.flows!)[0] as FlowType;
|
||||
});
|
||||
const { supported } = FlowTypes[type];
|
||||
const t = useTranslations();
|
||||
const allFlows: Record<FlowType, FlowInfo> = useMemo(
|
||||
() => ({
|
||||
password: {
|
||||
name: t.resourceOwnerPassword,
|
||||
description: t.resourceOwnerPasswordDesc,
|
||||
supported: true,
|
||||
},
|
||||
clientCredentials: {
|
||||
name: t.clientCredentials,
|
||||
description: t.clientCredentialsDesc,
|
||||
supported: true,
|
||||
},
|
||||
authorizationCode: {
|
||||
name: t.authorizationCode,
|
||||
description: t.authorizationCodeDesc,
|
||||
supported: true,
|
||||
},
|
||||
implicit: {
|
||||
name: t.implicit,
|
||||
description: t.implicitDesc,
|
||||
supported: true,
|
||||
},
|
||||
deviceAuthorization: {
|
||||
name: t.deviceAuthorization,
|
||||
description: t.deviceAuthorizationDesc,
|
||||
supported: false,
|
||||
},
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
clientId: '',
|
||||
@@ -264,8 +274,8 @@ export function OauthDialog({
|
||||
{children}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Authorization</DialogTitle>
|
||||
<DialogDescription>Obtain the access token for API.</DialogDescription>
|
||||
<DialogTitle>{t.authorization}</DialogTitle>
|
||||
<DialogDescription>{t.obtainAccessToken}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
@@ -280,7 +290,7 @@ export function OauthDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(scheme.flows!).map((key) => {
|
||||
const { name, description } = FlowTypes[key as FlowType];
|
||||
const { name, description } = allFlows[key as FlowType];
|
||||
|
||||
return (
|
||||
<SelectItem key={key} value={key}>
|
||||
@@ -297,14 +307,12 @@ export function OauthDialog({
|
||||
type === 'implicit') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_id" className={cn(labelVariants())}>
|
||||
Client ID
|
||||
{t.clientId}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client ID of your OAuth application.
|
||||
</p>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientIdHint}</p>
|
||||
<Input
|
||||
id="client_id"
|
||||
placeholder="Enter value"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
@@ -315,14 +323,12 @@ export function OauthDialog({
|
||||
{(type === 'authorizationCode' || type === 'clientCredentials') && (
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="client_secret" className={cn(labelVariants())}>
|
||||
Client Secret
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<p className="text-fd-muted-foreground text-sm">
|
||||
The client secret of your OAuth application.
|
||||
</p>
|
||||
<p className="text-fd-muted-foreground text-sm">{t.clientSecretHint}</p>
|
||||
<Input
|
||||
id="client_secret"
|
||||
placeholder="Enter value"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
@@ -334,11 +340,11 @@ export function OauthDialog({
|
||||
<>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="username" className={cn(labelVariants())}>
|
||||
Username
|
||||
{t.usernameField}
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="Enter value"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
autoComplete="off"
|
||||
@@ -347,11 +353,11 @@ export function OauthDialog({
|
||||
</fieldset>
|
||||
<fieldset className="flex flex-col gap-1.5">
|
||||
<label htmlFor="password" className={cn(labelVariants())}>
|
||||
Client Secret
|
||||
{t.clientSecret}
|
||||
</label>
|
||||
<Input
|
||||
id="password"
|
||||
placeholder="Enter value"
|
||||
placeholder={t.inputPlaceholder}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
disabled={isLoading}
|
||||
@@ -360,7 +366,7 @@ export function OauthDialog({
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
{supported ? (
|
||||
{allFlows[type].supported ? (
|
||||
<>
|
||||
{error ? <p className="text-red-400 font-medium text-sm">{String(error)}</p> : null}
|
||||
<button
|
||||
@@ -372,12 +378,12 @@ export function OauthDialog({
|
||||
}),
|
||||
)}
|
||||
>
|
||||
{authCodeCallback.isLoading ? 'Fetching token...' : 'Submit'}
|
||||
{authCodeCallback.isLoading ? t.fetchingToken : t.submit}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-fd-muted-foreground bg-fd-muted p-2 rounded-lg border">
|
||||
Unsupported
|
||||
{t.unsupported}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -23,12 +23,14 @@ import type { ServerVariableObject } from '@/types';
|
||||
import type { NoReference } from '@/utils/schema';
|
||||
import { StfProvider, useFieldValue, useListener, useStf } from '@fumari/stf';
|
||||
import { EditIcon } from 'lucide-react';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>) {
|
||||
const { servers } = useServerContext();
|
||||
const { server, setServer, setServerVariables } = useServerSelectContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
@@ -47,7 +49,7 @@ export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>
|
||||
)}
|
||||
>
|
||||
<span className="px-2 py-0.5 -ms-2 font-medium rounded-lg border bg-fd-secondary text-fd-secondary-foreground shadow-sm">
|
||||
{server?.name ?? 'Server URL'}
|
||||
{server?.name ?? t.serverUrl}
|
||||
</span>
|
||||
<code className="truncate min-w-0 flex-1">
|
||||
{isMounted
|
||||
@@ -55,14 +57,14 @@ export default function ServerSelect(props: ComponentProps<typeof DialogTrigger>
|
||||
server ? resolveServerUrl(server.url, server.variables) : '/',
|
||||
window.location.origin,
|
||||
)
|
||||
: 'loading...'}
|
||||
: t.loading}
|
||||
</code>
|
||||
<EditIcon className="size-4" />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Server URL</DialogTitle>
|
||||
<DialogDescription>The base URL of your API endpoint.</DialogDescription>
|
||||
<DialogTitle>{t.serverUrl}</DialogTitle>
|
||||
<DialogDescription>{t.serverUrlDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select value={server?.url} onValueChange={setServer}>
|
||||
<SelectTrigger>
|
||||
@@ -143,6 +145,7 @@ function Field({
|
||||
variable: NoReference<ServerVariableObject>;
|
||||
fieldName: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [value, setValue] = useFieldValue([fieldName], {
|
||||
compute(currentValue) {
|
||||
return typeof currentValue === 'string' ? currentValue : undefined;
|
||||
@@ -171,7 +174,7 @@ function Field({
|
||||
id={fieldName}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Enter Value"
|
||||
placeholder={t.serverUrlFieldPlaceholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CircleCheck, CircleX } from 'lucide-react';
|
||||
import type { Translations } from '@/i18n';
|
||||
|
||||
interface StatusInfo {
|
||||
description: string;
|
||||
@@ -6,45 +7,37 @@ interface StatusInfo {
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
const statusMap: Record<number, StatusInfo> = {
|
||||
400: { description: 'Bad Request', color: 'text-red-500', icon: CircleX },
|
||||
401: {
|
||||
description: 'Unauthorized',
|
||||
color: 'text-red-500',
|
||||
icon: CircleX,
|
||||
},
|
||||
403: { description: 'Forbidden', color: 'text-red-500', icon: CircleX },
|
||||
404: {
|
||||
description: 'Not Found',
|
||||
color: 'text-fd-muted-foreground',
|
||||
icon: CircleX,
|
||||
},
|
||||
500: {
|
||||
description: 'Internal Server Error',
|
||||
color: 'text-red-500',
|
||||
icon: CircleX,
|
||||
},
|
||||
const statusKeys: Record<
|
||||
number,
|
||||
{ key: keyof Translations; color: string; icon: React.ElementType }
|
||||
> = {
|
||||
400: { key: 'statusBadRequest', color: 'text-red-500', icon: CircleX },
|
||||
401: { key: 'statusUnauthorized', color: 'text-red-500', icon: CircleX },
|
||||
403: { key: 'statusForbidden', color: 'text-red-500', icon: CircleX },
|
||||
404: { key: 'statusNotFound', color: 'text-fd-muted-foreground', icon: CircleX },
|
||||
500: { key: 'statusInternalServerError', color: 'text-red-500', icon: CircleX },
|
||||
};
|
||||
|
||||
export function getStatusInfo(status: number): StatusInfo {
|
||||
if (status in statusMap) {
|
||||
return statusMap[status];
|
||||
export function getStatusInfo(status: number, t: Translations): StatusInfo {
|
||||
if (status in statusKeys) {
|
||||
const { key, color, icon } = statusKeys[status];
|
||||
return { description: t[key], color, icon };
|
||||
}
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
return {
|
||||
description: 'Successful',
|
||||
description: t.statusSuccessful,
|
||||
color: 'text-green-500',
|
||||
icon: CircleCheck,
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return { description: 'Error', color: 'text-red-500', icon: CircleX };
|
||||
return { description: t.statusError, color: 'text-red-500', icon: CircleX };
|
||||
}
|
||||
|
||||
return {
|
||||
description: 'No Description',
|
||||
description: t.statusNoDescription,
|
||||
color: 'text-fd-muted-foreground',
|
||||
icon: CircleX,
|
||||
};
|
||||
|
||||
@@ -52,8 +52,8 @@ export interface RenderContext
|
||||
|
||||
renderHeading: (
|
||||
depth: number,
|
||||
text: string,
|
||||
props?: HTMLAttributes<HTMLHeadingElement>,
|
||||
text: string | ReactNode,
|
||||
props?: HTMLAttributes<HTMLHeadingElement> & { id?: string },
|
||||
) => ReactNode;
|
||||
renderCodeBlock: (lang: string, code: string) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -259,7 +259,8 @@ export function createAPIPage(
|
||||
},
|
||||
slugger,
|
||||
async renderHeading(depth, text, props) {
|
||||
const id = slugger.slug(text);
|
||||
const id = typeof text === 'string' ? slugger.slug(text) : props?.id;
|
||||
if (!id) throw new Error("missing 'id' for non-string children");
|
||||
|
||||
if (options.renderHeading) {
|
||||
return options.renderHeading({ id, children: text, ...props }, depth);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
import { defaultTranslations, type Translations } from '@/i18n';
|
||||
import { useI18n } from 'fumadocs-ui/contexts/i18n';
|
||||
|
||||
export function useTranslations(): Translations {
|
||||
return (useI18n().text.openapi ?? defaultTranslations) as unknown as Translations;
|
||||
}
|
||||
|
||||
export interface OpenAPII18nLabelProps {
|
||||
label: keyof Translations;
|
||||
/** Replace {key} placeholders in the translation string */
|
||||
replacements?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a translated string. Use in server components so the label is resolved on the client from the current locale.
|
||||
*/
|
||||
export function I18nLabel({ label, replacements = {} }: OpenAPII18nLabelProps): string {
|
||||
const text = useTranslations();
|
||||
let value = text[label];
|
||||
for (const [k, v] of Object.entries(replacements)) {
|
||||
value = value.replaceAll(`{${k}}`, v);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
@@ -32,30 +33,33 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 flex flex-col gap-4 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border bg-fd-popover p-4 shadow-lg rounded-xl duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
aria-label="Close"
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'absolute end-2 top-2 text-fd-muted-foreground/70',
|
||||
'fixed left-1/2 top-1/2 z-50 flex flex-col gap-4 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 border bg-fd-popover p-4 shadow-lg rounded-xl duration-200 data-[state=open]:animate-fd-dialog-in data-[state=closed]:animate-fd-dialog-out focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<X />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
aria-label={t.close}
|
||||
className={cn(
|
||||
buttonVariants({ size: 'icon-sm', color: 'ghost' }),
|
||||
'absolute end-2 top-2 text-fd-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
<X />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/ui/components/select';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
|
||||
export function CopyTypeScriptPanel({
|
||||
name,
|
||||
@@ -25,6 +26,8 @@ export function CopyTypeScriptPanel({
|
||||
const [isChecked, onCopy] = useCopyButton(() => {
|
||||
void navigator.clipboard.writeText(code);
|
||||
});
|
||||
const t = useTranslations();
|
||||
const useTypeText = t.useTypeInTypeScript.replace('{name}', name);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -34,8 +37,8 @@ export function CopyTypeScriptPanel({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm mb-2">TypeScript Definitions</p>
|
||||
<p className="text-xs text-fd-muted-foreground">Use the {name} type in TypeScript.</p>
|
||||
<p className="font-medium text-sm mb-2">{t.typeScriptDefinitions}</p>
|
||||
<p className="text-xs text-fd-muted-foreground">{useTypeText}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
@@ -48,7 +51,7 @@ export function CopyTypeScriptPanel({
|
||||
)}
|
||||
>
|
||||
{isChecked ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
Copy
|
||||
{t.copy}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, Fragment, type ReactNode } from 'react';
|
||||
import { ComponentProps, Fragment, type ReactNode } from 'react';
|
||||
import type {
|
||||
CallbackObject,
|
||||
MethodInformation,
|
||||
@@ -11,6 +11,7 @@ import { Schema } from '../schema';
|
||||
import { UsageTabs } from '@/ui/operation/usage-tabs';
|
||||
import { Badge, MethodLabel } from '@/ui/components/method-label';
|
||||
import { CopyTypeScriptPanel, SelectTab, SelectTabs, SelectTabTrigger } from './client';
|
||||
import { I18nLabel } from '@/ui/client/i18n';
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionHeader,
|
||||
@@ -19,18 +20,13 @@ import {
|
||||
AccordionTrigger,
|
||||
} from '@/ui/components/accordion';
|
||||
import { isMediaTypeSupported } from '@/requests/media/adapter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { APIPlayground } from '@/playground';
|
||||
import { getExampleRequests, RequestTabs } from './request-tabs';
|
||||
import { UsageTabsProviderLazy } from './usage-tabs/lazy';
|
||||
import { ServerProviderLazy } from '../contexts/api.lazy';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
const ParamTypes = {
|
||||
path: 'Path Parameters',
|
||||
query: 'Query Parameters',
|
||||
header: 'Header Parameters',
|
||||
cookie: 'Cookie Parameters',
|
||||
};
|
||||
const paramTypeKeys = ['path', 'query', 'header', 'cookie'] as const;
|
||||
|
||||
export async function Operation({
|
||||
type = 'operation',
|
||||
@@ -80,7 +76,8 @@ export async function Operation({
|
||||
bodyNode = (
|
||||
<SelectTabs defaultValue={items[0].value}>
|
||||
<div className="flex gap-2 items-center justify-between mt-10">
|
||||
{ctx.renderHeading(headingLevel, 'Request Body', {
|
||||
{ctx.renderHeading(headingLevel, <I18nLabel label="titleRequestBody" />, {
|
||||
id: 'request-body',
|
||||
className: 'my-0!',
|
||||
})}
|
||||
{contentTypes.length > 1 ? (
|
||||
@@ -130,7 +127,9 @@ export async function Operation({
|
||||
|
||||
responseNode = (
|
||||
<>
|
||||
{ctx.renderHeading(headingLevel, 'Response Body')}
|
||||
{ctx.renderHeading(headingLevel, <I18nLabel label="titleResponseBody" />, {
|
||||
id: 'response-body',
|
||||
})}
|
||||
|
||||
<Accordions type="multiple">
|
||||
{statuses.map((status) => (
|
||||
@@ -141,13 +140,15 @@ export async function Operation({
|
||||
);
|
||||
}
|
||||
|
||||
const parameterNode = Object.entries(ParamTypes).map(([type, title]) => {
|
||||
const parameterNode = paramTypeKeys.map((type) => {
|
||||
const params = method.parameters?.filter((param) => param.in === type);
|
||||
if (!params || params.length === 0) return;
|
||||
|
||||
return (
|
||||
<Fragment key={type}>
|
||||
{ctx.renderHeading(headingLevel, title)}
|
||||
{ctx.renderHeading(headingLevel, <I18nLabel label={`${type}Parameters`} />, {
|
||||
id: `parameters-${type}`,
|
||||
})}
|
||||
<div className="flex flex-col">
|
||||
{params.map(
|
||||
(param) =>
|
||||
@@ -206,7 +207,8 @@ export async function Operation({
|
||||
authNode = (
|
||||
<SelectTabs defaultValue={items[0].value}>
|
||||
<div className="flex items-start justify-between gap-2 mt-10">
|
||||
{ctx.renderHeading(headingLevel, 'Authorization', {
|
||||
{ctx.renderHeading(headingLevel, <I18nLabel label="authorization" />, {
|
||||
id: 'authorization',
|
||||
className: 'my-0!',
|
||||
})}
|
||||
{items.length > 1 ? (
|
||||
@@ -239,7 +241,8 @@ export async function Operation({
|
||||
callbacksNode = (
|
||||
<SelectTabs defaultValue={items[0].value}>
|
||||
<div className="flex justify-between gap-2 items-end mt-10">
|
||||
{ctx.renderHeading(headingLevel, 'Callbacks', {
|
||||
{ctx.renderHeading(headingLevel, <I18nLabel label="titleCallbacks" />, {
|
||||
id: 'callbacks',
|
||||
className: 'my-0!',
|
||||
})}
|
||||
{callbacks.length > 1 ? (
|
||||
@@ -483,16 +486,20 @@ function AuthScheme({
|
||||
if (scheme.type === 'http' || scheme.type === 'oauth2') {
|
||||
return (
|
||||
<AuthProperty
|
||||
name="Authorization"
|
||||
name={<I18nLabel label="authorization" />}
|
||||
type={
|
||||
scheme.type === 'http' && scheme.scheme === 'basic' ? `Basic <token>` : 'Bearer <token>'
|
||||
scheme.type === 'http' && scheme.scheme === 'basic' ? (
|
||||
<I18nLabel label="authBasicTokenExample" />
|
||||
) : (
|
||||
<I18nLabel label="authBearerTokenExample" />
|
||||
)
|
||||
}
|
||||
deprecated={scheme.deprecated}
|
||||
scopes={scopes}
|
||||
>
|
||||
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
||||
<p>
|
||||
In: <code>header</code>
|
||||
<I18nLabel label="authTokenIn" />: <code>header</code>
|
||||
</p>
|
||||
</AuthProperty>
|
||||
);
|
||||
@@ -508,7 +515,7 @@ function AuthScheme({
|
||||
>
|
||||
{scheme.description && ctx.renderMarkdown(scheme.description)}
|
||||
<p>
|
||||
In: <code>{scheme.in}</code>
|
||||
<I18nLabel label="authTokenIn" />: <code>{scheme.in}</code>
|
||||
</p>
|
||||
</AuthProperty>
|
||||
);
|
||||
@@ -517,7 +524,7 @@ function AuthScheme({
|
||||
if (scheme.type === 'openIdConnect') {
|
||||
return (
|
||||
<AuthProperty
|
||||
name="OpenID Connect"
|
||||
name={<I18nLabel label="openIdConnect" />}
|
||||
type="<token>"
|
||||
deprecated={scheme.deprecated}
|
||||
scopes={scopes}
|
||||
@@ -536,23 +543,28 @@ function AuthProperty({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'> & {
|
||||
name: string;
|
||||
type: string;
|
||||
name: ReactNode;
|
||||
type: ReactNode;
|
||||
deprecated?: boolean;
|
||||
scopes?: string[];
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('text-sm border-t my-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}</span>
|
||||
<span className="text-sm font-mono text-fd-muted-foreground">{type}</span>
|
||||
{deprecated && <Badge color="red">Deprecated</Badge>}
|
||||
{deprecated && (
|
||||
<Badge color="red" className="text-xs">
|
||||
<I18nLabel label="deprecated" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose-no-margin pt-2.5 empty:hidden">
|
||||
{props.children}
|
||||
{scopes.length > 0 && (
|
||||
<p>
|
||||
Scope: <code>{scopes.join(', ')}</code>
|
||||
<I18nLabel label="authScope" />: <code>{scopes.join(', ')}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MethodInformation, RenderContext } from '@/types';
|
||||
import { getPreferredType, type NoReference, pickExample } from '@/utils/schema';
|
||||
import { I18nLabel } from '@/ui/client/i18n';
|
||||
import {
|
||||
AccordionContent,
|
||||
AccordionHeader,
|
||||
@@ -165,16 +166,16 @@ function renderRequestTabsDefault(
|
||||
const displayNames: Partial<Record<keyof RawRequestData, ReactNode>> = {
|
||||
body: (
|
||||
<>
|
||||
Body
|
||||
<I18nLabel label="titleRequestBody" />
|
||||
<code className="text-xs text-fd-muted-foreground ms-auto">
|
||||
{requestData.bodyMediaType}
|
||||
</code>
|
||||
</>
|
||||
),
|
||||
cookie: 'Cookie',
|
||||
header: 'Header',
|
||||
query: 'Query Parameters',
|
||||
path: 'Path Parameters',
|
||||
cookie: <I18nLabel label="cookieParameters" />,
|
||||
header: <I18nLabel label="headerParameters" />,
|
||||
query: <I18nLabel label="queryParameters" />,
|
||||
path: <I18nLabel label="pathParameters" />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -213,7 +214,7 @@ function renderRequestTabsDefault(
|
||||
<TabsList>
|
||||
{items.map((item) => (
|
||||
<TabsTrigger key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
{item.id === '_default' ? <I18nLabel label="requestTabNameDefault" /> : item.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
@@ -227,12 +228,18 @@ function renderRequestTabsDefault(
|
||||
} else if (items.length === 1) {
|
||||
children = renderItem(items[0]);
|
||||
} else {
|
||||
children = <p className="text-fd-muted-foreground text-xs">Empty</p>;
|
||||
children = (
|
||||
<p className="text-fd-muted-foreground text-xs">
|
||||
<I18nLabel label="empty" />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 rounded-xl border prose-no-margin bg-fd-card text-fd-card-foreground shadow-md">
|
||||
<p className="font-semibold border-b pb-2">Example Requests</p>
|
||||
<p className="font-semibold border-b pb-2">
|
||||
<I18nLabel label="titleRequestTabs" />
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
import { sample } from 'openapi-sampler';
|
||||
import type { ReactNode } from 'react';
|
||||
import { I18nLabel } from '@/ui/client/i18n';
|
||||
|
||||
export interface ResponseTab {
|
||||
/**
|
||||
@@ -32,7 +33,7 @@ interface ResponseExample {
|
||||
*/
|
||||
sample: unknown;
|
||||
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
|
||||
/**
|
||||
* description (in Markdown)
|
||||
@@ -64,10 +65,8 @@ export function ResponseTabs({
|
||||
tab.examples ??= [];
|
||||
|
||||
for (const [key, sample] of Object.entries(responseOfType.examples)) {
|
||||
const title = sample?.summary || `Example ${key}`;
|
||||
|
||||
tab.examples.push({
|
||||
label: title,
|
||||
label: sample?.summary ?? <I18nLabel label="responseTabName" replacements={{ key }} />,
|
||||
sample: sample.value,
|
||||
description: sample?.description,
|
||||
});
|
||||
@@ -75,7 +74,7 @@ export function ResponseTabs({
|
||||
} else if (responseOfType?.example || responseOfType?.schema) {
|
||||
tab.examples ??= [];
|
||||
tab.examples.push({
|
||||
label: 'Example',
|
||||
label: <I18nLabel label="responseTabNameDefault" />,
|
||||
sample: responseOfType.example ?? sample(responseOfType.schema as object),
|
||||
});
|
||||
}
|
||||
@@ -103,12 +102,12 @@ function renderResponseTabsDefault(
|
||||
async function renderResponse(tab: ResponseTab) {
|
||||
const { examples = [] } = tab;
|
||||
|
||||
let slot: ReactNode = 'Empty';
|
||||
let slot: ReactNode = <I18nLabel label="empty" />;
|
||||
if (examples.length > 1) {
|
||||
slot = (
|
||||
<Accordions type="single" className="pt-2" defaultValue={examples[0].label}>
|
||||
<Accordions type="single" className="pt-2" defaultValue="0">
|
||||
{examples.map((example, i) => (
|
||||
<AccordionItem key={i} value={example.label}>
|
||||
<AccordionItem key={i} value={i.toString()}>
|
||||
<AccordionHeader>
|
||||
<AccordionTrigger>{example.label}</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs';
|
||||
import type { InfoTag, SchemaDataObjectProperty, SchemaUIGeneratedData } from '@/ui/schema';
|
||||
import { useTranslations } from '@/ui/client/i18n';
|
||||
import type {
|
||||
InfoTag,
|
||||
SchemaData,
|
||||
SchemaDataObjectProperty,
|
||||
SchemaUIGeneratedData,
|
||||
} from '@/ui/schema';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -139,23 +145,7 @@ function SchemaUIProperty({
|
||||
$ref: $type,
|
||||
});
|
||||
} else if (schema.type === 'array') {
|
||||
if (variant === 'expand')
|
||||
return (
|
||||
<Collapsible className="my-2">
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
buttonVariants({ color: 'secondary', size: 'sm' }),
|
||||
'group px-3 py-2 data-[state=open]:rounded-b-none',
|
||||
)}
|
||||
>
|
||||
Array Item
|
||||
<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>
|
||||
);
|
||||
if (variant === 'expand') return <ArrayItemCollapsible schema={schema} />;
|
||||
|
||||
type = renderRef({
|
||||
pathName: name,
|
||||
@@ -168,8 +158,8 @@ function SchemaUIProperty({
|
||||
{schema.description}
|
||||
{schema.infoTags && schema.infoTags.length > 0 && (
|
||||
<div className="flex flex-row gap-2 flex-wrap my-2 not-prose empty:hidden">
|
||||
{schema.infoTags.map((tag) => (
|
||||
<InfoTag key={tag.label} tag={tag} />
|
||||
{schema.infoTags.map((tag, i) => (
|
||||
<InfoTag key={i} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -183,6 +173,28 @@ function SchemaUIProperty({
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayItemCollapsible({ schema }: { schema: Extract<SchemaData, { type: 'array' }> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ObjectSearchProps {
|
||||
properties: SchemaDataObjectProperty[];
|
||||
container?: ComponentProps<'div'>;
|
||||
@@ -194,6 +206,7 @@ function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
|
||||
const deferredValue = useDeferredValue(search);
|
||||
const firstItemRef = useRef<SchemaDataObjectProperty>(null);
|
||||
const prevProperties = useRef(properties);
|
||||
const t = useTranslations();
|
||||
|
||||
if (prevProperties.current !== properties) {
|
||||
prevProperties.current = properties;
|
||||
@@ -214,7 +227,7 @@ function ObjectSearch({ properties, container, open }: ObjectSearchProps) {
|
||||
value={search}
|
||||
data-object-search-input=""
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter Properties"
|
||||
placeholder={t.schemaFilterPropertiesPlaceholder}
|
||||
className="text-sm ps-2 py-2 flex-1 outline-none placeholder:text-fd-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && open) {
|
||||
@@ -244,6 +257,7 @@ function ObjectSearchContent({
|
||||
firstItemRef: RefObject<SchemaDataObjectProperty | null>;
|
||||
properties: SchemaDataObjectProperty[];
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const filtered = useMemo(() => {
|
||||
const search = rawSearch.trim().toLowerCase();
|
||||
return search.length > 0
|
||||
@@ -256,7 +270,7 @@ function ObjectSearchContent({
|
||||
if (filtered.length === 0)
|
||||
return (
|
||||
<p className="text-fd-muted-foreground text-sm px-2">
|
||||
No property matching{' '}
|
||||
{t.schemaFilterPropertiesEmpty}{' '}
|
||||
<span className="text-fd-foreground font-medium">{`"${rawSearch}"`}</span>
|
||||
</p>
|
||||
);
|
||||
@@ -504,6 +518,7 @@ function Property({
|
||||
className,
|
||||
...props
|
||||
}: PropertyProps) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -530,7 +545,7 @@ function Property({
|
||||
)}
|
||||
{deprecated && (
|
||||
<Badge color="yellow" className="ms-auto text-xs">
|
||||
Deprecated
|
||||
{t.deprecated}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormatFlags, schemaToString } from '@/utils/schema-to-string';
|
||||
import { mergeAllOf } from '@/utils/merge-schema';
|
||||
import type { SchemaUIProps } from '@/ui/schema/client';
|
||||
import { SchemaUILazy } from '@/ui/schema/lazy';
|
||||
import { I18nLabel } from '../client/i18n';
|
||||
|
||||
export interface FieldBase {
|
||||
description?: ReactNode;
|
||||
@@ -17,7 +18,7 @@ export interface FieldBase {
|
||||
}
|
||||
|
||||
export interface InfoTag {
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -101,19 +102,25 @@ export function generateSchemaUI(
|
||||
const fields: InfoTag[] = [];
|
||||
|
||||
if (schema.default !== undefined) {
|
||||
fields.push({ label: 'Default', value: JSON.stringify(schema.default) });
|
||||
fields.push({
|
||||
label: <I18nLabel label="schemaDefault" />,
|
||||
value: JSON.stringify(schema.default),
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.pattern) {
|
||||
fields.push({ label: 'Match', value: schema.pattern });
|
||||
fields.push({ label: <I18nLabel label="schemaMatch" />, value: schema.pattern });
|
||||
}
|
||||
|
||||
if (schema.format) {
|
||||
fields.push({ label: 'Format', value: schema.format });
|
||||
fields.push({ label: <I18nLabel label="schemaFormat" />, value: schema.format });
|
||||
}
|
||||
|
||||
if (schema.multipleOf) {
|
||||
fields.push({ label: 'Multiple Of', value: schema.multipleOf.toString() });
|
||||
fields.push({
|
||||
label: <I18nLabel label="schemaMultipleOf" />,
|
||||
value: schema.multipleOf.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
let range = formatRange(
|
||||
@@ -123,10 +130,10 @@ export function generateSchemaUI(
|
||||
schema.maximum,
|
||||
schema.exclusiveMaximum,
|
||||
);
|
||||
if (range) fields.push({ label: 'Range', value: range });
|
||||
if (range) fields.push({ label: <I18nLabel label="schemaRange" />, value: range });
|
||||
|
||||
range = formatRange('length', schema.minLength, undefined, schema.maxLength, undefined);
|
||||
if (range) fields.push({ label: 'Length', value: range });
|
||||
if (range) fields.push({ label: <I18nLabel label="schemaLength" />, value: range });
|
||||
|
||||
range = formatRange(
|
||||
'properties',
|
||||
@@ -135,21 +142,24 @@ export function generateSchemaUI(
|
||||
schema.maxProperties,
|
||||
undefined,
|
||||
);
|
||||
if (range) fields.push({ label: 'Properties', value: range });
|
||||
if (range) fields.push({ label: <I18nLabel label="schemaProperties" />, value: range });
|
||||
|
||||
range = formatRange('items', schema.minItems, undefined, schema.maxItems, undefined);
|
||||
if (range) fields.push({ label: 'Items', value: range });
|
||||
if (range) fields.push({ label: <I18nLabel label="schemaItems" />, value: range });
|
||||
|
||||
if (schema.enum) {
|
||||
fields.push({
|
||||
label: 'Value in',
|
||||
label: <I18nLabel label="schemaValueIn" />,
|
||||
value: schema.enum.map((value) => JSON.stringify(value)).join(' | '),
|
||||
});
|
||||
}
|
||||
|
||||
if (showExample && schema.examples) {
|
||||
for (const example of schema.examples) {
|
||||
fields.push({ label: 'Example', value: JSON.stringify(example, null, 2) });
|
||||
fields.push({
|
||||
label: <I18nLabel label="schemaExample" />,
|
||||
value: JSON.stringify(example, null, 2),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
format: 'esm',
|
||||
target: 'es2023',
|
||||
entry: [
|
||||
'./src/index.ts',
|
||||
'./src/{index,i18n}.ts',
|
||||
'./src/ui/index.ts',
|
||||
'./src/ui/base.tsx',
|
||||
'./src/ui/client/index.tsx',
|
||||
|
||||
@@ -10,15 +10,22 @@ export interface I18nUIConfig<Languages extends string> extends I18nConfig<Langu
|
||||
provider: (locale?: Languages | (string & {})) => I18nProviderProps;
|
||||
}
|
||||
|
||||
type TranslationsConfig<Languages extends string> = {
|
||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||
};
|
||||
|
||||
export function defineI18nUI<Languages extends string>(
|
||||
config: I18nConfig<Languages>,
|
||||
options: {
|
||||
translations: {
|
||||
[K in Languages]?: Partial<Translations> & { displayName?: string };
|
||||
};
|
||||
},
|
||||
options:
|
||||
| {
|
||||
/**
|
||||
* @deprecated you can directly define the translations in outer scope (the parent object of `translations`)
|
||||
*/
|
||||
translations: TranslationsConfig<Languages>;
|
||||
}
|
||||
| TranslationsConfig<Languages> = {},
|
||||
): I18nUIConfig<Languages> {
|
||||
const { translations } = options;
|
||||
const translations = 'translations' in options ? options.translations : options;
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
||||
Generated
+3
@@ -2409,6 +2409,9 @@ importers:
|
||||
|
||||
packages/openapi:
|
||||
dependencies:
|
||||
'@fastify/deepmerge':
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
'@fumari/json-schema-ts':
|
||||
specifier: ^0.0.2
|
||||
version: 0.0.2([email protected])
|
||||
|
||||
Reference in New Issue
Block a user