OpenAPI: Implement i18n

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