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 });
```
### 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: '搜尋文檔',
},
});
+12 -14
View File
@@ -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)',
},
});
+10 -7
View File
@@ -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
+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} />`.
*/
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,
+2
View File
@@ -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",
+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 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}
/>
);
}
+17 -24
View File
@@ -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,
};
+2 -2
View File
@@ -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;
}
+2 -1
View File
@@ -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);
+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 { 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>) => (
+6 -3
View File
@@ -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>
);
+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 {
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>
+38 -23
View File
@@ -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>
+21 -11
View File
@@ -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),
});
}
}
+1 -1
View File
@@ -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',
+13 -6
View File
@@ -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,
+3
View File
@@ -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])