feat(asyncapi): implement

This commit is contained in:
Fuma Nama
2026-06-07 20:08:57 +08:00
committed by Fuma Nama
parent 3e847746c7
commit 74102c5655
66 changed files with 6980 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@fumadocs/asyncapi': patch
---
Implement
@@ -0,0 +1,134 @@
---
title: createAsyncAPIPage()
description: The component for rendering AsyncAPI docs content
---
## Overview
Fumadocs AsyncAPI uses a `<AsyncAPIPage />` component to render page contents, it should be a client component.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
// config
});
```
### Custom Layout
You can customize how operations and message examples are laid out.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
content: {
renderOperationLayout(slots) {
return (
<div className="flex flex-col gap-6">
{slots.header}
{slots.description}
{slots.channel}
{slots.messages}
{slots.messageExamples}
</div>
);
},
},
});
```
### Schema UI
Customize how JSON schemas are rendered, or disable schema examples.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
schemaUI: {
showExample: true,
render(options, ctx) {
// fully custom schema renderer
return ctx.SchemaUI(options);
},
},
});
```
### Internationalization
Assuming you have configured [Internationalization](/docs/internationalization) at UI level:
```tsx title="layout.shared.tsx"
import { defineI18n } from 'fumadocs-core/i18n';
import { uiTranslations } from 'fumadocs-ui/i18n';
import { asyncapiTranslations } from '@fumadocs/asyncapi/i18n';
const i18n = defineI18n({
languages: ['en', 'cn'],
defaultLanguage: 'en',
});
export const translations = i18n
.translations()
.extend(uiTranslations())
// [!code ++]
.extend(asyncapiTranslations())
.add('ui', {
cn: { displayName: 'Chinese' },
})
// [!code ++:5]
.add('asyncapi', {
cn: {
titleMessages: '消息',
},
});
```
See [Translations](/docs/ui/translations) for more details.
### Syntax Highlighting
AsyncAPI pages use Shiki for code blocks. You can customize the highlighter and themes.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
export const AsyncAPIPage = createAsyncAPIPage({
shiki: defaultShikiFactory,
shikiOptions: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
},
});
```
### Custom Components
Override the default heading and code block components.
```tsx title="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage({
components: {
Heading({ id, depth, ...props }) {
const Tag = `h${depth}` as const;
return <Tag id={id} {...props} />;
},
CodeBlock({ lang, code }) {
return <pre data-lang={lang}>{code}</pre>;
},
},
});
```
@@ -0,0 +1,250 @@
---
title: generateFiles()
description: Generate pages from AsyncAPI schema.
---
The `generateFiles()` function generates MDX files for API documentation.
Note that the page content is still rendered by the `<AsyncAPIPage />` component, the docs generator only arranges the operations to render for each page.
## Usage
It takes an AsyncAPI instance.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
import { asyncapi } from '@/lib/asyncapi';
void generateFiles({
input: asyncapi,
// The output directory.
output: '/content/docs',
});
```
### `per`
Customize the content of pages, default to `operation`.
<Callout>
Operation refers to an AsyncAPI operation with a specific action like
`receiveLightMeasurement:receive`.
</Callout>
| mode | content | file path |
| --------- | ------------------------------ | ------------------- |
| tag | operations with same tag | `{tag_name}.mdx` |
| file | operations in same schema file | `{file_name}.mdx` |
| operation | each operation | `{operationId}.mdx` |
| custom | see below | N/A |
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
per: 'tag',
});
```
When set to `custom`, you can pass a function to fully customize the generation process:
```ts title="Example"
import { generateFiles, OperationOutput } from '@fumadocs/asyncapi';
void generateFiles({
per: 'custom',
toPages(builder) {
const items = builder.extract();
for (const op of items.operations) {
const { operation, channel, displayName } = builder.fromExtractedOperation(op)!;
const entry: OperationOutput = {
type: 'operation',
schemaId: builder.id,
item: op,
path: '...',
info: {
title: displayName,
description: operation.description ?? channel.description,
},
};
builder.create(entry);
}
},
});
```
When set to `tag`, adding `x-displayName` to the tag definition can control the title of each page.
```yaml title="asyncapi.yaml"
tags:
- name: lighting
description: Operations related to streetlights.
x-displayName: Lighting
```
### `groupBy`
In `operation` mode, you can group output files with folders.
| value | output |
| -------------- | ------------------------------------- |
| tag | `{tag}/{operationId}.mdx` |
| channel | `{channel_address}/{operationId}.mdx` |
| none (default) | `{operationId}.mdx` |
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
per: 'operation',
groupBy: 'tag',
});
```
### `index`
Generate index files with cards linking to generated pages.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
index: {
// for generating `href`
url: {
baseUrl: '/docs',
contentDir: './content/docs',
},
items: [
{
// output path
path: 'index.mdx',
// optional: restrict the input files (identical to the `input` field in server)
only: ['streetlights.yaml'],
// optional: set frontmatter
description: 'All available pages',
},
],
},
});
```
### `meta`
Generate [`meta.json`](/docs/page-conventions#meta) files automatically.
You can always define the `meta.json` files manually for maximal flexibility.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
meta: true,
// or customize how folders are represented:
meta: {
folderStyle: 'separator',
},
});
```
### `imports`
Add custom imports to generated MDX files. This is useful for including components, utilities, or other dependencies in your generated documentation.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
imports: [
{
names: ['API_BASE_URL'],
from: '@/constants',
},
],
});
```
This will add the following imports to all generated MDX files:
```ts
import { API_BASE_URL } from '@/constants';
```
### `name`
A function that controls the output file name of MDX pages.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
name(output) {
if (output.type === 'operation') {
const { operation } = this.fromExtractedOperation(output.item)!;
console.log(operation);
return `my-dir/${output.item.id}`;
}
return 'my-dir/filename';
},
});
```
You can also use the `algorithm` option:
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
// v2 (default): use operation id
// v1: use `{operationId}/{action}`
name: { algorithm: 'v1' },
});
```
### `frontmatter`
Customize the frontmatter of MDX files.
By default, it includes:
| property | description |
| ------------- | ---------------------------------- |
| `title` | Page title |
| `description` | Page description |
| `full` | Always true, added for Fumadocs UI |
| `_asyncapi` | Internal Properties |
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
frontmatter: (title, description) => ({
myProperty: 'hello',
}),
});
```
### `addGeneratedComment`
Add a comment to the top of generated files indicating they are auto-generated.
```ts
import { generateFiles } from '@fumadocs/asyncapi';
void generateFiles({
// Add default comment
addGeneratedComment: true,
// Or provide a custom comment
addGeneratedComment: 'Custom auto-generated comment',
// Or disable comments
addGeneratedComment: false,
});
```
@@ -0,0 +1,347 @@
---
title: AsyncAPI
description: Generating docs for AsyncAPI schema.
---
## Setup
Install the required packages.
```npm
npm i @fumadocs/asyncapi shiki
```
### Generate Styles
Add the following line:
```css title="Tailwind CSS"
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
/* [!code ++] */
@import '@fumadocs/asyncapi/css/preset.css';
```
### Configure Plugin
Create the AsyncAPI server instance & `<AsyncAPIPage />` component.
```ts tab="lib/asyncapi.ts"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
// note: this is a server-side API
export const asyncapi = createAsyncAPI({
// the AsyncAPI schema, you can also give it an external URL.
input: ['./asyncapi.yaml'],
});
```
```tsx tab="components/api-page.tsx"
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage();
```
```ts tab="lib/source.ts"
import { asyncapiPlugin } from '@fumadocs/asyncapi/server';
import { loader } from 'fumadocs-core/source';
export const source = loader({
// [!code ++] optional: adds a send/receive badge to each page item in page tree
plugins: [asyncapiPlugin()],
});
```
See [`createAsyncAPI()`](/docs/integrations/asyncapi/server) & [`createAsyncAPIPage()`](/docs/integrations/asyncapi/api-page) for available options.
### Generate Pages
<Tabs items={["MDX Files", "Virtual Files"]}>
<Tab>
You can generate MDX files directly from your AsyncAPI schema.
Create a script:
```js title="scripts/generate-docs.ts"
import { generateFiles } from '@fumadocs/asyncapi';
import { asyncapi } from '@/lib/asyncapi';
void generateFiles({
input: asyncapi,
output: './content/docs',
// we recommend to enable it
// make sure your operation description doesn't break MDX syntax.
includeDescription: true,
});
```
Generate docs with the script:
```bash
bun ./scripts/generate-docs.ts
```
Add the `AsyncAPIPage` component to your MDX components. Generated files use the `<APIPage />` tag.
```tsx tab="Server Components" title="app/docs/[[...slug]]/page.tsx"
import { source } from '@/lib/source';
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
import { getMDXComponents } from '@/components/mdx';
// e.g. in your page renderer
export default function Page({ slug }) {
const page = source.getPage(slug);
const MdxContent = page.data.body;
return (
<MdxContent
components={getMDXComponents({
// [!code ++:3] add the MDX component
APIPage: async (props) => (
<AsyncAPIPage {...await asyncapi.preloadAsyncAPIPage(page)} {...props} />
),
})}
/>
);
}
```
```tsx tab="Without Server Components" title="docs/[...slug]"
import { source } from '@/lib/source';
import { asyncapi } from '@/lib/asyncapi';
import { AsyncAPIPage } from '@/components/api-page';
import { getMDXComponents } from '@/components/mdx';
// e.g. where you return server payload to client
export function loader({ slug }) {
const page = source.getPage(slug);
return {
// [!code ++]
asyncapiData: await asyncapi.preloadAsyncAPIPage(page),
// ...
};
}
// e.g. in your page renderer
export default function Page() {
// consume loader data
const { asyncapiData } = useLoaderData();
return (
<MdxContent
components={getMDXComponents({
// [!code ++] add the MDX component
APIPage: (props) => <AsyncAPIPage {...asyncapiData} {...props} />,
})}
/>
);
}
```
</Tab>
<Tab>
You can also use it without generating real files by integrating into [Loader API](/docs/headless/source-api/source).
```ts title="lib/source.ts"
import { loader } from 'fumadocs-core/source';
import { docs } from 'collections/server';
import { asyncapi } from '@/lib/asyncapi';
export const source = loader(
// [!code ++:6]
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
}),
},
{
baseUrl: '/docs',
plugins: [asyncapi.loaderPlugin()],
// ...
},
);
```
`staticSource()` is a server-side API that generates pages directly to your `loader()`, hence it allows dynamic content generation, such as re-generating page tree as schema changes.
**It will change the type of your pages**, make sure to update all references to your `source`.
For example, where you return text for LLM:
```ts
import { source } from '@/lib/source';
export async function getLLMText(page: (typeof source)['$inferPage']) {
// [!code ++:4]
if (page.type === 'asyncapi') {
// e.g. return the stringified AsyncAPI schema
return JSON.stringify(page.data.getSchema().bundled, null, 2);
}
// your original flow below...
}
```
And update your page renderer:
<Tabs items={["Server Components", "Without Server Components"]}>
<Tab>
```tsx title="docs/[[...slug]]/page.tsx"
import { AsyncAPIPage } from '@/components/api-page';
export default function Page({ slug }) {
const page = source.getPage(slug);
// for AsyncAPI pages
if (page.type === 'asyncapi') {
return (
<DocsPage full>
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
<DocsBody>
<AsyncAPIPage {...page.data.getAsyncAPIPageProps()} />
</DocsBody>
</DocsPage>
);
}
// your original flow below...
}
```
</Tab>
<Tab>
Pass a client payload from server, then render the page using the `<AsyncAPIPage />` component you created above.
For example, in Tanstack Start:
```tsx title="routes/docs/$.tsx"
import { createFileRoute, notFound } from '@tanstack/react-router';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { createServerFn } from '@tanstack/react-start';
import { source } from '@/lib/source';
import browserCollections from 'collections/browser';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { type ReactNode, Suspense } from 'react';
import { AsyncAPIPage } from '@/components/api-page';
export const Route = createFileRoute('/docs/$')({
component: Page,
loader: async ({ params }) => {
const slugs = params._splat?.split('/') ?? [];
const data = await serverLoader({ data: slugs });
// Fumadocs MDX: only preload content for normal pages [!code highlight:3]
if (data.type === 'docs') {
await clientLoader.preload(data.path);
}
return data;
},
});
const serverLoader = createServerFn({
method: 'GET',
})
.inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = source.getPage(slugs);
if (!page) throw notFound();
const pageTree = await source.serializePageTree(source.getPageTree());
// different result for AsyncAPI pages [!code ++:9]
if (page.type === 'asyncapi') {
return {
type: 'asyncapi',
title: page.data.title,
description: page.data.description,
pageTree,
props: page.data.getAsyncAPIPageProps(),
};
}
return {
type: 'docs',
path: page.path,
pageTree,
// ...
};
});
const clientLoader = browserCollections.docs.createClientLoader({
component(pageData, props) {
// ...
},
});
function Page() {
const page = useFumadocsLoader(Route.useLoaderData());
let content: ReactNode;
// render AsyncAPI page content [!code ++:11]
if (page.type === 'asyncapi') {
content = (
<DocsPage full>
<DocsTitle>{page.title}</DocsTitle>
<DocsDescription>{page.description}</DocsDescription>
<DocsBody>
{/* pass the payload data */}
<AsyncAPIPage {...page.props} />
</DocsBody>
</DocsPage>
);
} else {
content = clientLoader.useContent(page.path, page);
}
return (
<DocsLayout tree={page.pageTree}>
<Suspense>{content}</Suspense>
</DocsLayout>
);
}
```
You can see the full [AsyncAPI example](https://github.com/fuma-nama/fumadocs/blob/dev/examples/asyncapi).
</Tab>
</Tabs>
<Callout type='warn' title="Ensure the migration is complete!">
Run a type check to verify before continuing, e.g.
```npm
npm run types:check
```
</Callout>
</Tab>
</Tabs>
## Features
The official AsyncAPI integration supports:
- Basic operation information (send/receive)
- Channel, message, and reply documentation
- Message payload & header schemas
- Security schemes
- Message examples (from schema examples or generated samples)
- Bindings and traits
### Demo
See the [AsyncAPI example](https://github.com/fuma-nama/fumadocs/blob/dev/examples/asyncapi).
@@ -0,0 +1,115 @@
---
title: createAsyncAPI()
description: The AsyncAPI server instance.
---
## AsyncAPI Server
The main config for Fumadocs AsyncAPI.
> It should not be referenced in browser environments.
### `input`
The AsyncAPI schemas to read from.
- File Paths
- External URLs
- Functions (see below)
```ts tab="Basic"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: ['./streetlights.yaml'],
});
```
```ts tab="Functions"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: {
// [id]: downloaded AsyncAPI Schema
galaxy: async () => {
const res = await fetch('https://example.com/asyncapi.yaml');
return res.text();
},
},
});
```
### `disableCache`
Disable caching of loaded schemas. Useful during development when schemas change frequently.
```ts
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
input: ['./asyncapi.yaml'],
disableCache: true,
});
```
## Creating Proxy
A proxy server is useful for executing HTTP (`fetch`) requests, as it doesn't have CORS constraints like on the browser.
<Callout type="warn" title="Warning">
Do not use this on unreliable sites and API endpoints, the proxy server will forward all received
headers & body, including HTTP-only `Cookies` and `Authorization` header.
</Callout>
### Setup
Create a route handler for proxy server.
```ts tab="Next.js" title="app/api/proxy/route.ts"
import { asyncapi } from '@/lib/asyncapi';
export const { GET, HEAD, PUT, POST, PATCH, DELETE } = asyncapi.createProxy({
// optional, we recommend to set a list of allowed origins for proxied requests
allowedOrigins: ['https://example.com'],
});
```
```ts tab="Tanstack Start" title="app/routes/api/proxy.ts"
import { asyncapi } from '@/lib/asyncapi';
import { createFileRoute } from '@tanstack/react-router';
const proxy = asyncapi.createProxy({
// optional, we recommend to set a list of allowed origins for proxied requests
allowedOrigins: ['https://example.com'],
});
export const Route = createFileRoute('/api/proxy')({
server: {
handlers: {
// Handles all methods by default, or specify individual methods (GET, POST, etc.)
'*': async ({ request }) => {
return proxy.handle(request);
},
},
},
});
```
```ts tab="Waku" title="src/pages/_api/api/proxy.ts"
import { asyncapi } from '@/lib/asyncapi';
export const { GET, HEAD, PUT, POST, PATCH, DELETE } = asyncapi.createProxy({
// optional, we recommend to set a list of allowed origins for proxied requests
allowedOrigins: ['https://example.com'],
});
```
And set the proxy URL in `createAsyncAPI`.
```ts title="lib/asyncapi.ts"
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
proxyUrl: '/api/proxy', // [!code ++]
});
```
+1
View File
@@ -0,0 +1 @@
content/docs/(api)/**/*.mdx
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/lib/layout.shared';
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link';
export default function HomePage() {
return (
<main
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
textAlign: 'center',
justifyContent: 'center',
}}
>
<h1
style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '1rem',
}}
>
Hello World
</h1>
<p>
You can open{' '}
<Link
href="/docs"
style={{
fontWeight: '600',
textDecoration: 'underline',
}}
>
/docs
</Link>{' '}
and see the documentation.
</p>
</main>
);
}
@@ -0,0 +1,4 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
@@ -0,0 +1,21 @@
import { generateOGImage } from 'fumadocs-ui/og';
import { source } from '@/lib/source';
import { notFound } from 'next/navigation';
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
});
}
export function generateStaticParams() {
return source.generateParams().map((page) => ({
...page,
slug: [...page.slug, 'image.png'],
}));
}
@@ -0,0 +1,64 @@
import { source } from '@/lib/source';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page';
import { notFound } from 'next/navigation';
import { getMDXComponents } from '@/components/mdx';
import type { Metadata } from 'next';
import { AsyncAPIPage } from '@/components/api-page';
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) {
notFound();
}
if (page.type === 'asyncapi') {
return (
<DocsPage toc={page.data.toc} full>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<AsyncAPIPage {...page.data.getAsyncAPIPageProps()} />
</DocsBody>
</DocsPage>
);
}
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc} full={page.data.full}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={getMDXComponents()} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const image = ['/docs-og', ...(params.slug ?? []), 'image.png'].join('/');
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: image,
},
twitter: {
card: 'summary_large_image',
images: image,
},
};
}
+12
View File
@@ -0,0 +1,12 @@
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
export default function RootDocsLayout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.getPageTree()} {...baseOptions()}>
{children}
</DocsLayout>
);
}
+17
View File
@@ -0,0 +1,17 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/solar.css';
@import 'fumadocs-ui/css/preset.css';
@import '@fumadocs/asyncapi/css/preset.css';
html {
scrollbar-gutter: stable;
}
html > body[data-scroll-locked] {
margin-right: 0px !important;
--removed-body-scroll-bar-size: 0px !important;
}
:root {
--fd-layout-width: 1600px;
}
+23
View File
@@ -0,0 +1,23 @@
import { RootProvider } from 'fumadocs-ui/provider/next';
import './global.css';
import { Inter, JetBrains_Mono } from 'next/font/google';
import type { ReactNode } from 'react';
const inter = Inter({
subsets: ['latin'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
});
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${inter.className} ${mono.variable}`} suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}
@@ -0,0 +1,4 @@
'use client';
import { createAsyncAPIPage } from '@fumadocs/asyncapi/ui';
export const AsyncAPIPage = createAsyncAPIPage();
+15
View File
@@ -0,0 +1,15 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...components,
} satisfies MDXComponents;
}
export const useMDXComponents = getMDXComponents;
declare global {
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
}
+12
View File
@@ -0,0 +1,12 @@
---
title: Authentication
description: How security is defined in the Scalar Galaxy Events AsyncAPI spec.
---
Security requirements are defined per operation in the AsyncAPI document. Supported schemes depend on the protocol and server configuration.
## Security Schemes
Check the **Authorization** section on each operation page for the applicable security schemes and scopes.
When connecting to a broker or gateway, configure credentials according to your environment (for example API keys, SASL, or TLS client certificates).
+19
View File
@@ -0,0 +1,19 @@
---
title: Overview
description: General information about the Scalar Galaxy Events API.
---
Scalar Galaxy Events is an AsyncAPI 3.0 specification that documents real-time notifications and event-driven capabilities for the Scalar Galaxy API.
## Event-Driven Features
- **Planet Updates**: Get notified when planets are created, updated, or experience cosmic events
- **User Activity Streams**: Track user signups, authentication events, and profile changes
- **Celestial Body Monitoring**: Monitor changes to satellites, asteroids, and other celestial objects
- **System Notifications**: Receive alerts about system maintenance, rate limits, and security events
## Operations
AsyncAPI operations use `send` and `receive` actions instead of HTTP methods. Each operation references a channel and the messages it can process.
Browse the generated reference pages under **References** to explore channels, messages, bindings, and security schemes from the spec.
+3
View File
@@ -0,0 +1,3 @@
{
"pages": ["---API Documentation---", "index", "...", "---References---", "...(asyncapi)"]
}
+9
View File
@@ -0,0 +1,9 @@
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: 'AsyncAPI Playground',
},
};
}
+22
View File
@@ -0,0 +1,22 @@
import { loader } from 'fumadocs-core/source';
import { docs } from 'collections/server';
import { createAsyncAPI } from '@fumadocs/asyncapi/server';
export const asyncapi = createAsyncAPI({
// input files
input: ['./scalar.yaml'],
});
export const source = loader(
{
docs: docs.toFumadocsSource(),
asyncapi: await asyncapi.staticSource({
baseDir: 'asyncapi',
groupBy: 'tag',
}),
},
{
baseUrl: '/docs',
plugins: [asyncapi.loaderPlugin()],
},
);
+7
View File
@@ -0,0 +1,7 @@
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
export default withMDX({
reactStrictMode: true,
});
+31
View File
@@ -0,0 +1,31 @@
{
"name": "example-asyncapi",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"types:check": "fumadocs-mdx && tsc --noEmit"
},
"dependencies": {
"@fumadocs/asyncapi": "workspace:*",
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "16.2.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"shiki": "^4.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.0",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.15",
"rimraf": "^6.1.3",
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import { defineDocs } from 'fumadocs-mdx/config';
export const docs = defineDocs({
dir: 'content/docs',
});
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"paths": {
"@/*": ["./*"],
"collections/*": ["./.source/*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.mts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+2
View File
@@ -0,0 +1,2 @@
@import './generated/shared.css';
@import '@fumadocs/api-docs/css/preset.css';
+9 -14
View File
@@ -15,6 +15,14 @@
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.js",
"./i18n": "./dist/i18n.js",
"./server": "./dist/server/index.js",
"./ui": "./dist/ui/index.js",
"./package.json": "./package.json",
"./css/*": "./css/*"
},
"publishConfig": {
"access": "public"
},
@@ -41,23 +49,18 @@
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@scalar/api-client-react": "^2.0.21",
"@scalar/openapi-upgrader": "^0.2.9",
"@tailwindcss/oxide": "^4.3.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "25.9.1",
"@types/react": "^19.2.16",
"fast-content-type-parse": "^3.0.0",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"json-schema-typed": "^8.0.2",
"tailwindcss": "^4.3.0",
"tsconfig": "workspace:*",
"tsdown": "0.22.1",
"xml-js": "^1.6.11"
"tsdown": "0.22.1"
},
"peerDependencies": {
"@scalar/api-client-react": "^2.0.20",
"@types/react": "*",
"fumadocs-core": "^16.9.0",
"fumadocs-ui": "^16.9.0",
@@ -71,14 +74,6 @@
},
"@types/react": {
"optional": true
},
"@scalar/api-client-react": {
"optional": true
}
},
"inlinedDependencies": {
"@scalar/openapi-upgrader": "0.2.9",
"fast-content-type-parse": "3.0.0",
"xml-js": "1.6.11"
}
}
+266
View File
@@ -0,0 +1,266 @@
import { mkdir, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import { generateDocument, type PagesToTextOptions, toText } from './utils/pages/to-text';
import type { AsyncAPIServer } from '@/server';
import { createGetUrl, getSlugs, PathUtils } from 'fumadocs-core/source';
import { createAutoPreset, type SchemaToPagesOptions } from '@/utils/pages/preset-auto';
import { fromSchema, type OutputGroup, type OutputEntry } from '@/utils/pages/builder';
import type { DistributiveOmit } from './types';
import { doubleQuote } from './utils/string-utils';
export interface OutputFile {
path: string;
content: string;
}
interface IndexConfig {
items: IndexItem[] | ((ctx: BeforeWriteContext) => IndexItem[]);
url:
| ((filePath: string) => string)
| {
baseUrl: string;
contentDir: string;
};
}
interface IndexItem {
path: string;
title?: string;
description?: string;
only?: string[];
}
interface GenerateFilesConfig extends PagesToTextOptions {
input: AsyncAPIServer;
output: string;
index?: IndexConfig;
meta?: boolean | MetaOptions;
beforeWrite?: (this: BeforeWriteContext, files: OutputFile[]) => void | Promise<void>;
}
interface MetaOptions {
groupStyle?: 'folder' | 'separator';
folderStyle?: 'folder' | 'separator';
}
export type Config = SchemaToPagesOptions &
GenerateFilesConfig & {
watch?: boolean;
};
interface BeforeWriteContext {
readonly generated: Record<string, OutputFile[]>;
readonly generatedEntries: Record<string, OutputEntry[]>;
}
export async function generateFiles(options: Config): Promise<void> {
if (options.watch) {
const { watch } = await import('chokidar');
const subOptions: Config = { ...options, watch: false };
await generateFiles(subOptions);
const targets = options.input._getWatchPaths();
console.log(`[Fumadocs AsyncAPI] watching ${targets.join(', ')}`);
watch(targets, {
ignoreInitial: true,
}).on('all', () => generateFiles(subOptions));
return;
}
const files = await generateFilesOnly(options);
await Promise.all(
files.map(async (file) => {
const filePath = path.join(options.output, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
console.log(`Generated: ${filePath}`);
}),
);
}
export async function generateFilesOnly(
options: DistributiveOmit<Config, 'output'>,
): Promise<OutputFile[]> {
const schemas = await options.input.getSchemas();
const files: OutputFile[] = [];
const generated: Record<string, OutputFile[]> = {};
const generatedEntries: Record<string, OutputEntry[]> = {};
const entries = Object.entries(schemas);
if (entries.length === 0) {
throw new Error('No input files found.');
}
const preset = createAutoPreset(options);
for (const [id, schema] of entries) {
const entries = fromSchema(id, schema.bundled, preset);
const schemaFiles: OutputFile[] = [];
generatedEntries[id] = entries;
function scan(entry: OutputEntry) {
if (entry.type === 'group') {
for (const child of entry.entries) scan(child);
return;
}
schemaFiles.push({
path: entry.path,
content: toText(entry, schema.bundled, options),
});
}
for (const entry of entries) scan(entry);
generated[id] = schemaFiles;
files.push(...schemaFiles);
}
const context: BeforeWriteContext = {
generated,
generatedEntries,
};
if (options.index) {
files.push(...writeIndexFiles(context, options));
}
if (options.meta) {
files.push(...generateMeta(context, options.meta === true ? {} : options.meta));
}
await options.beforeWrite?.call(context, files);
return files;
}
function generateMeta(context: BeforeWriteContext, options: MetaOptions): OutputFile[] {
const files: OutputFile[] = [];
const folderStyle = options.folderStyle ?? options.groupStyle ?? 'folder';
function scan(entries: OutputEntry[], parent?: OutputGroup) {
const pages: string[] = [];
for (const entry of entries) {
const relativePath = PathUtils.slash(
parent ? path.relative(parent.path, entry.path) : entry.path,
);
if (entry.type === 'group') {
scan(entry.entries, entry);
if (folderStyle === 'folder') {
pages.push(relativePath);
} else {
pages.push(`---${entry.info.title}---`, `...${relativePath}`);
}
} else {
pages.push(relativePath.slice(0, -path.extname(entry.path).length));
}
}
if (pages.length === 0) return;
files.push({
path: parent ? path.join(parent.path, 'meta.json') : 'meta.json',
content: JSON.stringify(
{
title: parent?.info.title,
description: parent?.info.description,
pages,
},
null,
2,
),
});
}
for (const entries of Object.values(context.generatedEntries)) {
scan(entries);
}
return files;
}
function writeIndexFiles(
context: BeforeWriteContext,
options: SchemaToPagesOptions & Omit<GenerateFilesConfig, 'output'>,
): OutputFile[] {
const files: OutputFile[] = [];
const { generatedEntries } = context;
const pathToEntry = new Map<string, OutputEntry>();
const { items, url } = options.index!;
function indexEntry(entry: OutputEntry) {
pathToEntry.set(PathUtils.slash(entry.path), entry);
if (entry.type === 'group') {
for (const child of entry.entries) {
indexEntry(child);
}
}
}
let urlFn: (path: string) => string;
if (typeof url === 'object') {
const getUrl = createGetUrl(url.baseUrl);
urlFn = (file) => getUrl(getSlugs(path.relative(url.contentDir, file)));
} else {
urlFn = url;
}
function fileContent(index: IndexItem): string {
const content: string[] = [];
content.push('<Cards>');
const outputEntries: OutputEntry[] = [];
const only = index.only ?? Object.keys(context.generated);
for (const item of only) {
if (generatedEntries[item]) {
for (const entry of generatedEntries[item]) {
outputEntries.push(entry);
}
} else {
const match = pathToEntry.get(item);
if (!match) {
throw new Error(
`${item} does not exist on "input", available: ${Object.keys(generatedEntries).join(', ')}.`,
);
}
outputEntries.push(match);
}
}
for (const entry of outputEntries) {
if (entry.type === 'group') continue;
const descriptionAttr = entry.info.description
? `description=${doubleQuote(entry.info.description)} `
: '';
content.push(
`<Card href="${urlFn(entry.path)}" title=${doubleQuote(entry.info.title)} ${descriptionAttr}/>`,
);
}
content.push('</Cards>');
return generateDocument(
{
title: index.title ?? 'Overview',
description: index.description,
},
content.join('\n'),
options,
);
}
for (const list of Object.values(context.generatedEntries)) {
for (const item of list) indexEntry(item);
}
for (const item of typeof items === 'function' ? items(context) : items) {
files.push({
path: path.extname(item.path).length === 0 ? `${item.path}.mdx` : item.path,
content: fileContent(item),
});
}
return files;
}
+52
View File
@@ -0,0 +1,52 @@
import type { TranslationsAPIExtension, TranslationValue } from 'fumadocs-core/i18n';
import { defaultTranslations as apiDocsTranslations } from '@fumadocs/api-docs/i18n';
export const defaultTranslations = {
...apiDocsTranslations,
loading: 'loading...',
empty: 'Empty',
copy: 'Copy',
send: 'Send',
receive: 'Receive',
authorization: 'Authorization',
deprecated: 'Deprecated',
close: 'Close',
titleMessages: 'Messages',
titleReply: 'Reply',
titleChannel: 'Channel',
titleBindings: 'Bindings',
titleParameters: 'Parameters',
titleHeaders: 'Headers',
titlePayload: 'Payload',
titleServers: 'Servers',
titleTraits: 'Traits',
titleCorrelationId: 'Correlation ID',
channelAddress: 'Address',
channelMessages: 'Messages',
serverProtocol: 'Protocol',
serverHost: 'Host',
authTokenIn: 'In',
authScope: 'Scope',
openIdConnect: 'OpenID Connect',
authBearerTokenExample: 'Bearer <token>',
authBasicTokenExample: 'Basic <token>',
typeScriptDefinitions: 'TypeScript Definitions',
useTypeInTypeScript: 'Use the {name} type in TypeScript.' as TranslationValue<'name'>,
messageExample: 'Example {key}' as TranslationValue<'key'>,
messageExampleDefault: 'Example',
};
export type Translations = typeof defaultTranslations;
export function asyncapiTranslations(): TranslationsAPIExtension<'asyncapi', Translations> {
return {
namespace: 'asyncapi',
defaultValue: defaultTranslations,
};
}
+14
View File
@@ -0,0 +1,14 @@
export * from './generate-file';
export type { RenderContext } from './types';
export type {
OperationOutput,
OutputEntry,
OutputGroup,
PageOutput,
PagesBuilder,
PagesBuilderConfig,
OperationItem,
GeneratedPageProps,
} from './utils/pages/builder';
export type { SchemaToPagesOptions } from './utils/pages/preset-auto';
export type * as AsyncAPI from '@/types/asyncapi-3';
+292
View File
@@ -0,0 +1,292 @@
import { createProxy } from '@/server/proxy';
import { loadDocument } from '@/utils/document/load';
import type { AsyncAPIObject, Awaitable } from '@/types';
import fs from 'node:fs';
import {
type DynamicSource,
type LoaderPlugin,
type PageData,
PathUtils,
type MetaData,
type Source,
type VirtualFile,
type Page,
} from 'fumadocs-core/source';
import {
getPageProps,
type OperationOutput,
type OutputEntry,
type PageOutput,
} from '@/utils/pages/builder';
import { toStaticData } from '@/utils/pages/to-static-data';
import path from 'node:path';
import type { AsyncAPIPageProps_Preloaded, AsyncAPIPageProps_Spec } from '@/ui';
import type { StructuredData } from 'fumadocs-core/mdx-plugins/remark-structure';
import type { TOCItemType } from 'fumadocs-core/toc';
import type { SchemaToPagesOptions } from '@/utils/pages/preset-auto';
import { ActionLabel } from '@/ui/components/method-label';
type SchemaRecord = Record<
string,
string | AsyncAPIObject | (() => Awaitable<string | AsyncAPIObject>)
>;
interface LoadedDocument {
bundled: AsyncAPIObject;
}
export interface AsyncAPIOptions {
input?: string[] | SchemaRecord;
disableCache?: boolean;
proxyUrl?: string;
}
export type { AsyncAPIPageProps_Spec, AsyncAPIPageProps_Preloaded };
export interface AsyncAPIServer {
createProxy: typeof createProxy;
getSchemas: () => Promise<Record<string, LoadedDocument>>;
getSchema: (document: string) => Promise<LoadedDocument>;
readonly options: AsyncAPIOptions;
staticSource: (
options?: AsyncAPISourceOptions,
) => Promise<Source<{ metaData: MetaData; pageData: AsyncAPIPageData }>>;
dynamicSource: (
options?: AsyncAPISourceOptions,
) => DynamicSource<{ metaData: MetaData; pageData: AsyncAPIPageData }>;
preloadAsyncAPIPage: <Type extends string | undefined, Data extends PageData>(
page: Page<Type, Data>,
) => Promise<Pick<AsyncAPIPageProps_Preloaded, 'preloaded'>>;
loaderPlugin: () => LoaderPlugin;
_getWatchPaths: () => string[];
}
export interface AsyncAPIPageData extends PageData {
getAsyncAPIPageProps: () => AsyncAPIPageProps_Spec;
getSchema: () => { id: string; bundled: AsyncAPIObject };
structuredData: StructuredData;
toc: TOCItemType[];
_asyncapi: InternalAsyncAPIMeta;
}
export type AsyncAPISourceOptions = SchemaToPagesOptions & {
baseDir?: string;
meta?: boolean | { folderStyle?: 'folder' | 'separator' };
};
export function createAsyncAPI(options: AsyncAPIOptions = {}): AsyncAPIServer {
const { disableCache = false } = options;
const schemaMap = new Map<string, Promise<LoadedDocument>>();
let resolvedInput: SchemaRecord = {};
if (Array.isArray(options.input)) {
for (const item of options.input) resolvedInput[item] = item;
} else if (options.input) {
resolvedInput = options.input;
}
function getSchema(schemaId: string): Promise<LoadedDocument> {
if (!(schemaId in resolvedInput)) {
console.warn(
`[Fumadocs AsyncAPI] the document "${schemaId}" is not listed in the input array, this may be unexpected and won't be cached properly.`,
);
return loadDocument(schemaId);
}
if (!disableCache) {
const cached = schemaMap.get(schemaId);
if (cached) return cached;
}
const raw = resolvedInput[schemaId];
const output = Promise.resolve(typeof raw === 'function' ? raw() : raw).then(loadDocument);
if (!disableCache) schemaMap.set(schemaId, output);
return output;
}
async function getSchemas(): Promise<Record<string, LoadedDocument>> {
const entries = await Promise.all(
Object.keys(resolvedInput).map(async (k) => [k, await getSchema(k)]),
);
return Object.fromEntries(entries);
}
async function getVirtualFiles(server: AsyncAPIServer, options: AsyncAPISourceOptions) {
const { baseDir = '', meta = false } = options;
const { createAutoPreset } = await import('@/utils/pages/preset-auto');
const { fromSchema } = await import('@/utils/pages/builder');
const files: VirtualFile<{
pageData: AsyncAPIPageData;
metaData: MetaData;
}>[] = [];
const schemas = await server.getSchemas();
const builderOptions = createAutoPreset(options);
for (const [id, schema] of Object.entries(schemas)) {
const list = fromSchema(id, schema.bundled, builderOptions);
onEntries(list);
function onEntry(entry: PageOutput | OperationOutput) {
const props = getPageProps(entry);
files.push({
type: 'page',
path: `${baseDir}/${entry.path}`,
data: {
...entry.info,
getAsyncAPIPageProps() {
return {
payload: {
bundled: schema.bundled,
proxyUrl: server.options.proxyUrl,
},
...props,
};
},
getSchema() {
return {
id,
bundled: schema.bundled,
};
},
...toStaticData(props, schema.bundled),
_asyncapi: {
action: entry.type === 'operation' ? entry.item.action : undefined,
deprecated: entry.info.deprecated,
},
},
});
}
function onEntries(entries: OutputEntry[], parent?: OutputEntry) {
if (!meta) {
for (const entry of entries) {
if (entry.type === 'group') {
onEntries(entry.entries, entry);
} else {
onEntry(entry);
}
}
return;
}
const { folderStyle = 'folder' } = meta === true ? {} : meta;
const pages: string[] = [];
for (const entry of entries) {
const relativePath = PathUtils.slash(
parent ? path.relative(parent.path, entry.path) : entry.path,
);
if (entry.type === 'group') {
onEntries(entry.entries, entry);
if (folderStyle === 'folder') {
pages.push(relativePath);
} else {
pages.push(`---${entry.info.title}---`, `...${relativePath}`);
}
} else {
onEntry(entry);
pages.push(relativePath.slice(0, -path.extname(entry.path).length));
}
}
if (pages.length === 0) return;
files.push({
type: 'meta',
path: path.join(baseDir, parent?.path ?? '', 'meta.json'),
data: {
title: parent?.info.title,
description: parent?.info.description,
pages,
},
});
}
}
return files;
}
return {
options,
createProxy,
_getWatchPaths() {
return Object.keys(resolvedInput).filter((key) => !URL.canParse(key) && fs.existsSync(key));
},
async preloadAsyncAPIPage(page) {
const out: AsyncAPIPageProps_Preloaded['preloaded'] = {
docs: {},
proxyUrl: options.proxyUrl,
};
const asyncapiMeta = (page.data as { _asyncapi?: InternalAsyncAPIMeta })._asyncapi;
if (asyncapiMeta?.preload) {
out.docs = Object.fromEntries(
await Promise.all(
asyncapiMeta.preload.map(async (k) => [k, (await getSchema(k)).bundled]),
),
);
}
return { preloaded: out };
},
getSchema,
getSchemas,
async staticSource(options = {}) {
return {
files: await getVirtualFiles(this, options),
};
},
dynamicSource(options = {}) {
return {
files: () => getVirtualFiles(this, options),
};
},
loaderPlugin() {
return asyncapiPlugin();
},
};
}
export interface InternalAsyncAPIMeta {
action?: string;
deprecated?: boolean;
preload?: string[];
}
export function asyncapiPlugin(): LoaderPlugin {
return {
name: 'fumadocs:asyncapi',
enforce: 'pre',
transformPageTree: {
file(node, filePath) {
if (!filePath) return node;
const file = this.storage.read(filePath);
if (!file || file.format !== 'page') return node;
const asyncapiData = (file.data as { _asyncapi?: InternalAsyncAPIMeta })._asyncapi;
if (!asyncapiData || typeof asyncapiData !== 'object') return node;
if (asyncapiData.deprecated) {
node.name = <span className="fd-page-tree-item-name line-through">{node.name}</span>;
}
if (asyncapiData.action) {
node.name = (
<>
{node.name}{' '}
<ActionLabel className="ms-auto text-xs text-nowrap">
{asyncapiData.action}
</ActionLabel>
</>
);
}
return node;
},
},
};
}
export type { CreateProxyOptions, Proxy } from './proxy';
+147
View File
@@ -0,0 +1,147 @@
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'] as const;
const methodsWithBody = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
type Handler = (req: Request) => Promise<Response>;
export interface Proxy extends Record<(typeof methods)[number], Handler> {
handle: Handler;
}
export interface CreateProxyOptions {
/**
* Filter by prefixes of request url
*
* @deprecated Use `allowedOrigins` for filtering origins, or `filterRequest` for more detailed rules.
*/
allowedUrls?: string[];
/**
* List of allowed origins to proxy to.
*/
allowedOrigins?: string[];
/**
* Determine if the proxied request is allowed.
*
* @returns true if allowed, otherwise forbidden.
*/
filterRequest?: (request: Request) => boolean;
/**
* Override proxied request/response with yours
*/
overrides?: {
request?: (request: Request) => Request;
response?: (response: Response) => Response;
};
}
export function createProxy(options: CreateProxyOptions = {}): Proxy {
const {
allowedOrigins,
allowedUrls,
filterRequest = (req) => {
return !allowedUrls || allowedUrls.some((item) => req.url.startsWith(item));
},
overrides,
} = options;
const handlers: Partial<Proxy> = {
handle: handler,
};
async function handler(req: Request): Promise<Response> {
const searchParams = new URL(req.url).searchParams;
const rawUrl = searchParams.get('url');
if (!rawUrl)
return Response.json('[Proxy] A `url` query parameter is required for proxy url', {
status: 400,
});
const targetUrl = URL.parse(rawUrl);
if (!targetUrl)
return Response.json('[Proxy] Invalid `url` parameter value.', {
status: 400,
});
if (allowedOrigins && !allowedOrigins.includes(targetUrl.origin)) {
return Response.json(`[Proxy] The origin "${targetUrl.origin}" is not allowed.`, {
status: 400,
});
}
const proxied = await rewriteRequest(req, targetUrl, searchParams.get('cookie'));
if (!filterRequest(proxied)) {
return Response.json('[Proxy] The proxied request is not allowed', {
status: 403,
});
}
try {
return rewriteResponse(await fetch(proxied));
} catch (err) {
return Response.json(
`[Proxy] Failed to proxy request: ${err instanceof Error ? err.message : 'unknown reason'}`,
{
status: 500,
},
);
}
}
async function rewriteRequest(
request: Request,
url: URL,
cookie: string | null,
): Promise<Request> {
const headers = new Headers(request.headers);
headers.delete('origin');
if (cookie) {
headers.set('Cookie', cookie);
}
const contentLength = headers.get('content-length');
const hasBody = contentLength && parseInt(contentLength) > 0;
const proxied = new Request(url, {
method: request.method,
cache: 'no-cache',
headers,
body:
hasBody && methodsWithBody.has(request.method.toUpperCase())
? await request.arrayBuffer()
: undefined,
});
return overrides?.request ? overrides.request(proxied) : proxied;
}
async function rewriteResponse(response: Response): Promise<Response> {
if (overrides?.response) {
response = overrides.response(response);
}
const headers = new Headers(response.headers);
headers.forEach((_value, originalKey) => {
const key = originalKey.toLowerCase();
if (key.startsWith('access-control-')) {
headers.delete(originalKey);
}
});
headers.set('X-Forwarded-Host', response.url);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
for (const key of methods) {
handlers[key] = handler;
}
return handlers as Proxy;
}
+40
View File
@@ -0,0 +1,40 @@
export type {
AsyncAPIObject,
ChannelObject,
MessageObject,
MessageExampleObject,
OperationObject,
SecuritySchemeObject,
ServerObject,
TagObject,
ReferenceObject,
ParameterObject,
AsyncAPISchemaObject,
MultiFormatSchemaObject,
OperationReplyObject,
} from './types/asyncapi-3';
import type { DereferencedDocument } from '@/utils/document/dereference';
import type { AsyncAPIOptions } from '@/server';
import type { CreateAsyncAPIPageOptions } from './ui';
import type { FC, ReactNode } from 'react';
import type { SchemaUIOptions } from '@fumadocs/api-docs/components/schema';
type RequireKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
export interface RenderContext
extends
Pick<AsyncAPIOptions, 'proxyUrl'>,
Omit<
RequireKeys<
CreateAsyncAPIPageOptions,
'generateTypeScriptDefinitions' | 'shikiOptions' | 'shiki'
>,
'schemaUI'
> {
schema: DereferencedDocument;
_default_processMarkdown: (md: string) => ReactNode;
SchemaUI: FC<Omit<SchemaUIOptions, 'resolver' | 'renderMarkdown'>>;
}
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type Awaitable<T> = T | Promise<T>;
+507
View File
@@ -0,0 +1,507 @@
/** From https://github.com/asyncapi/parser-js/blob/master/packages/parser/src/spec-types/v3.ts
* - Switched from `json-schema` to `json-schema-typed`
*/
import type { JSONSchema, $schema, TypeName } from 'json-schema-typed/draft-07';
export type AsyncAPIVersion = string;
export type Identifier = string;
export type DefaultContentType = string;
export interface AsyncAPIObject extends SpecificationExtensions {
asyncapi: AsyncAPIVersion;
id?: Identifier;
defaultContentType?: DefaultContentType;
info: InfoObject;
servers?: ServersObject;
channels?: ChannelsObject;
operations?: OperationsObject;
components?: ComponentsObject;
}
export interface InfoObject extends SpecificationExtensions {
title: string;
version: string;
description?: string;
termsOfService?: string;
contact?: ContactObject;
license?: LicenseObject;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
}
export interface ContactObject extends SpecificationExtensions {
name?: string;
url?: string;
email?: string;
}
export interface LicenseObject extends SpecificationExtensions {
name: string;
url?: string;
}
export type ServersObject = Record<string, ServerObject | ReferenceObject>;
export interface ServerObject extends SpecificationExtensions {
host: string;
protocol: string;
pathname?: string;
protocolVersion?: string;
description?: string;
variables?: Record<string, ServerVariableObject | ReferenceObject>;
security?: Array<SecuritySchemeObject | ReferenceObject>;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
bindings?: ServerBindingsObject | ReferenceObject;
}
export interface ServerVariableObject extends SpecificationExtensions {
enum?: Array<string>;
default?: string;
description?: string;
examples?: Array<string>;
}
export interface ServerBindingsObject extends SpecificationExtensions {
http?: Binding;
ws?: Binding;
kafka?: Binding;
anypointmq?: Binding;
amqp?: Binding;
amqp1?: Binding;
mqtt?: Binding;
mqtt5?: Binding;
nats?: Binding;
jms?: Binding;
sns?: Binding;
sqs?: Binding;
stomp?: Binding;
redis?: Binding;
mercure?: Binding;
ibmmq?: Binding;
googlepubsub?: Binding;
}
export type ChannelsObject = Record<string, ChannelObject | ReferenceObject>;
export interface ChannelObject extends SpecificationExtensions {
address?: string | null;
messages?: MessagesObject;
title?: string;
summary?: string;
description?: string;
servers?: Array<ServerObject | ReferenceObject>;
parameters?: ParametersObject;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
bindings?: ChannelBindingsObject | ReferenceObject;
}
export interface ChannelBindingsObject extends SpecificationExtensions {
http?: Binding;
ws?: Binding;
kafka?: Binding;
anypointmq?: Binding;
amqp?: Binding;
amqp1?: Binding;
mqtt?: Binding;
mqtt5?: Binding;
nats?: Binding;
jms?: Binding;
sns?: Binding;
sqs?: Binding;
stomp?: Binding;
redis?: Binding;
mercure?: Binding;
ibmmq?: Binding;
googlepubsub?: Binding;
}
export type OperationsObject = Record<string, OperationObject | ReferenceObject>;
export interface OperationObject extends SpecificationExtensions {
action: 'send' | 'receive';
channel: ChannelObject | ReferenceObject;
messages?: Array<MessageObject | ReferenceObject>;
reply?: OperationReplyObject | ReferenceObject;
title?: string;
summary?: string;
description?: string;
security?: Array<SecuritySchemeObject | ReferenceObject>;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
bindings?: OperationBindingsObject | ReferenceObject;
traits?: Array<OperationTraitObject | ReferenceObject>;
}
export interface OperationTraitObject extends SpecificationExtensions {
title?: string;
summary?: string;
description?: string;
security?: Array<SecuritySchemeObject | ReferenceObject>;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
bindings?: OperationBindingsObject | ReferenceObject;
}
export interface OperationReplyObject extends SpecificationExtensions {
channel?: ChannelObject | ReferenceObject;
messages?: (MessageObject | ReferenceObject)[];
address?: OperationReplyAddressObject | ReferenceObject;
}
export interface OperationReplyAddressObject extends SpecificationExtensions {
location: string;
description?: string;
}
export interface OperationBindingsObject extends SpecificationExtensions {
http?: Binding;
ws?: Binding;
kafka?: Binding;
anypointmq?: Binding;
amqp?: Binding;
amqp1?: Binding;
mqtt?: Binding;
mqtt5?: Binding;
nats?: Binding;
jms?: Binding;
sns?: Binding;
sqs?: Binding;
stomp?: Binding;
redis?: Binding;
mercure?: Binding;
ibmmq?: Binding;
googlepubsub?: Binding;
}
export type MessagesObject = Record<string, MessageObject | ReferenceObject>;
export interface MessageObject extends MessageTraitObject, SpecificationExtensions {
payload?: any;
traits?: Array<MessageTraitObject | ReferenceObject>;
}
export interface MessageTraitObject extends SpecificationExtensions {
headers?: MultiFormatSchemaObject;
correlationId?: CorrelationIDObject | ReferenceObject;
contentType?: string;
name?: string;
title?: string;
summary?: string;
description?: string;
tags?: TagsObject;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
bindings?: MessageBindingsObject | ReferenceObject;
examples?: Array<MessageExampleObject>;
}
export interface MessageExampleObject extends SpecificationExtensions {
name?: string;
summary?: string;
headers?: Record<string, any>;
payload?: any;
}
export interface MessageBindingsObject extends SpecificationExtensions {
http?: Binding;
ws?: Binding;
kafka?: Binding;
anypointmq?: Binding;
amqp?: Binding;
amqp1?: Binding;
mqtt?: Binding;
mqtt5?: Binding;
nats?: Binding;
jms?: Binding;
sns?: Binding;
sqs?: Binding;
stomp?: Binding;
redis?: Binding;
mercure?: Binding;
ibmmq?: Binding;
googlepubsub?: Binding;
}
export type ParametersObject = Record<string, ParameterObject | ReferenceObject>;
export interface ParameterObject extends SpecificationExtensions {
description?: string;
enum?: string[];
default?: string;
examples?: string[];
location?: string;
}
export type TagsObject = Array<TagObject | ReferenceObject>;
export interface TagObject extends SpecificationExtensions {
name: string;
description?: string;
externalDocs?: ExternalDocumentationObject | ReferenceObject;
}
export interface ExternalDocumentationObject extends SpecificationExtensions {
url: string;
description?: string;
}
export interface ComponentsObject extends SpecificationExtensions {
servers?: Record<string, ServerObject | ReferenceObject>;
channels?: Record<string, ChannelObject | ReferenceObject>;
operations?: Record<string, OperationObject | ReferenceObject>;
messages?: Record<string, MessageObject | ReferenceObject>;
schemas?: Record<string, SchemaObject | ReferenceObject>;
securitySchemes?: Record<string, SecuritySchemeObject | ReferenceObject>;
serverVariables?: Record<string, ServerVariableObject | ReferenceObject>;
parameters?: Record<string, ParameterObject | ReferenceObject>;
replies?: Record<string, OperationReplyObject | ReferenceObject>;
replyAddresses?: Record<string, OperationReplyAddressObject | ReferenceObject>;
correlationIds?: Record<string, CorrelationIDObject | ReferenceObject>;
operationTraits?: Record<string, OperationTraitObject | ReferenceObject>;
messageTraits?: Record<string, MessageTraitObject | ReferenceObject>;
tags?: Record<string, TagObject | ReferenceObject>;
externalDocs?: Record<string, ExternalDocumentationObject | ReferenceObject>;
serverBindings?: Record<string, ServerBindingsObject | ReferenceObject>;
channelBindings?: Record<string, ChannelBindingsObject | ReferenceObject>;
operationBindings?: Record<string, OperationBindingsObject | ReferenceObject>;
messageBindings?: Record<string, MessageBindingsObject | ReferenceObject>;
}
export interface SecuritySchemeObject extends SpecificationExtensions {
type: SecuritySchemeType;
description?: string;
name?: string;
in?: 'user' | 'password' | 'query' | 'header' | 'cookie';
scheme?: string;
bearerFormat?: string;
flows?: OAuthFlowsObject;
openIdConnectUrl?: string;
scopes?: string[];
}
export type SecuritySchemeType =
| 'userPassword'
| 'apiKey'
| 'X509'
| 'symmetricEncryption'
| 'asymmetricEncryption'
| 'httpApiKey'
| 'http'
| 'oauth2'
| 'openIdConnect'
| 'plain'
| 'scramSha256'
| 'scramSha512'
| 'gssapi';
export type SecuritySchemaLocation = 'user' | 'password' | 'query' | 'header' | 'cookie';
export interface SecuritySchemeObjectBase extends SpecificationExtensions {
description?: string;
}
export interface SecuritySchemeObjectUserPassword
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'userPassword';
}
export interface SecuritySchemeObjectApiKey
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'apiKey';
in: 'user' | 'password';
}
export interface SecuritySchemeObjectX509
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'X509';
}
export interface SecuritySchemeObjectSymetricEncryption
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'symmetricEncryption';
}
export interface SecuritySchemeObjectAsymetricEncryption
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'asymmetricEncryption';
}
export interface SecuritySchemeObjectHttpApiKey
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'httpApiKey';
name: string;
in: 'query' | 'header' | 'cookie';
}
export interface SecuritySchemeObjectHttp
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'http';
scheme: string;
bearerFormat?: string;
}
export interface SecuritySchemeObjectOauth2
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'oauth2';
flows: OAuthFlowsObject;
scopes: string[];
}
export interface SecuritySchemeObjectOpenIdConnect
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'openIdConnect';
openIdConnectUrl: string;
}
export interface SecuritySchemeObjectPlain
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'plain';
}
export interface SecuritySchemeObjectScramSha256
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'scramSha256';
}
export interface SecuritySchemeObjectScramSha512
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'scramSha512';
}
export interface SecuritySchemeObjectGssapi
extends SecuritySchemeObjectBase, SpecificationExtensions {
type: 'gssapi';
}
export interface OAuthFlowsObject extends SpecificationExtensions {
implicit?: OAuthFlowObjectImplicit;
password?: OAuthFlowObjectPassword;
clientCredentials?: OAuthFlowObjectClientCredentials;
authorizationCode?: OAuthFlowObjectAuthorizationCode;
}
export type OAuthFlowObject = OAuthFlowObjectImplicit &
OAuthFlowObjectPassword &
OAuthFlowObjectClientCredentials &
OAuthFlowObjectAuthorizationCode;
export interface OAuthFlowObjectBase extends SpecificationExtensions {
refreshUrl?: string;
availableScopes: Record<string, string>;
}
export interface OAuthFlowObjectImplicit extends OAuthFlowObjectBase, SpecificationExtensions {
authorizationUrl: string;
}
export interface OAuthFlowObjectPassword extends OAuthFlowObjectBase, SpecificationExtensions {
tokenUrl: string;
}
export interface OAuthFlowObjectClientCredentials
extends OAuthFlowObjectBase, SpecificationExtensions {
tokenUrl: string;
}
export interface OAuthFlowObjectAuthorizationCode
extends OAuthFlowObjectBase, SpecificationExtensions {
authorizationUrl: string;
tokenUrl: string;
}
export type SchemaObject = AsyncAPISchemaObject | ReferenceObject;
export type AsyncAPISchemaObject = AsyncAPISchemaDefinition | boolean;
export type MultiFormatObject = { schema: AsyncAPISchemaObject; schemaFormat: string | undefined };
export type MultiFormatSchemaObject = AsyncAPISchemaObject | MultiFormatObject;
export interface AsyncAPISchemaDefinition extends SpecificationExtensions {
$id?: string;
$schema?: typeof $schema;
$comment?: string;
type?: TypeName | TypeName[];
enum?: JSONSchema[];
const?: JSONSchema;
multipleOf?: number;
maximum?: number;
exclusiveMaximum?: number;
minimum?: number;
exclusiveMinimum?: number;
maxLength?: number;
minLength?: number;
pattern?: string;
items?: AsyncAPISchemaObject | AsyncAPISchemaObject[];
additionalItems?: AsyncAPISchemaObject;
maxItems?: number;
minItems?: number;
uniqueItems?: boolean;
contains?: AsyncAPISchemaObject;
maxProperties?: number;
minProperties?: number;
required?: string[];
properties?: {
[key: string]: AsyncAPISchemaObject;
};
patternProperties?: {
[key: string]: AsyncAPISchemaObject;
};
additionalProperties?: AsyncAPISchemaObject;
dependencies?: {
[key: string]: AsyncAPISchemaObject | string[];
};
propertyNames?: AsyncAPISchemaObject;
if?: AsyncAPISchemaObject;
then?: AsyncAPISchemaObject;
else?: AsyncAPISchemaObject;
allOf?: AsyncAPISchemaObject[];
anyOf?: AsyncAPISchemaObject[];
oneOf?: AsyncAPISchemaObject[];
not?: AsyncAPISchemaObject;
format?: string;
contentMediaType?: string;
contentEncoding?: string;
definitions?: {
[key: string]: AsyncAPISchemaObject;
};
title?: string;
description?: string;
default?: JSONSchema;
readOnly?: boolean;
writeOnly?: boolean;
examples?: JSONSchema[];
discriminator?: string;
externalDocs?: ExternalDocumentationObject;
deprecated?: boolean;
[keyword: string]: any;
}
export interface Binding {
bindingVersion?: string;
}
export interface SpecificationExtensions {
[extension: `x-${string}`]: SpecificationExtension;
}
export type SpecificationExtension<T = any> = T;
export interface ReferenceObject {
$ref: string;
}
export interface CorrelationIDObject extends SpecificationExtensions {
location: string;
description?: string;
}
+55
View File
@@ -0,0 +1,55 @@
import { Operation } from '@/ui/operation';
import type { AsyncAPIObject, ServerObject } from '@/types';
import type { GeneratedPageProps } from '@/utils/pages/builder';
import { ServerProvider, useRenderContext } from './contexts/api';
import { resolveOperation } from '@/utils/operation';
export function PageContent({
showTitle: hasHead = false,
showDescription,
operations,
}: Omit<GeneratedPageProps, 'document'>) {
const ctx = useRenderContext();
const { dereferenced } = ctx.schema;
let { renderPageLayout } = ctx.content ?? {};
renderPageLayout ??= (slots) => (
<div className="flex flex-col gap-24 text-sm @container">
{slots.operations?.map((op) => op.children)}
</div>
);
let content = renderPageLayout(
{
operations: operations?.map((item) => {
const resolved = resolveOperation(item.id, dereferenced as AsyncAPIObject);
if (!resolved)
throw new Error(`[Fumadocs AsyncAPI] Operation not found in schema: ${item.id}`);
return {
item,
children: (
<Operation
key={`${item.id}:${item.action}`}
id={item.id}
action={item.action}
operation={resolved.operation}
channel={resolved.channel}
messages={resolved.messages}
reply={resolved.reply}
showTitle={hasHead}
showDescription={showDescription}
/>
),
};
}),
},
ctx,
);
const servers = Object.values(dereferenced.servers ?? {}) as ServerObject[];
if (servers.length > 0) {
content = <ServerProvider servers={servers}>{content}</ServerProvider>;
}
return content;
}
+24
View File
@@ -0,0 +1,24 @@
'use client';
import { defaultTranslations, type Translations } from '@/i18n';
import { renderTranslation, TranslationValue } from 'fumadocs-core/i18n';
import { useTranslations as useTranslationsBase } from 'fumadocs-ui/contexts/i18n';
export function useTranslations() {
return useTranslationsBase<Translations>('asyncapi') ?? defaultTranslations;
}
/**
* Renders a translated string. Use in server components so the label is resolved on the client from the current locale.
*/
export function I18nLabel<K extends keyof Translations>({
label,
replacements,
}: {
label: K;
replacements?: Translations[K] extends TranslationValue<infer Params>
? Record<Params, string>
: never;
}): string {
const text = useTranslations();
return renderTranslation(text[label], replacements!);
}
@@ -0,0 +1,20 @@
import { useRenderContext } from '../contexts/api';
import { useMemo } from 'react';
type KeyName = 'server-url' | `auth-${string}`;
export function useStorageKey() {
const { storageKeyPrefix } = useRenderContext();
return useMemo(
() => ({
of: (name: KeyName) => getStorageKey(storageKeyPrefix, name),
AuthField: (schemeId: string) => getStorageKey(storageKeyPrefix, `auth-${schemeId}`),
}),
[storageKeyPrefix],
);
}
function getStorageKey(prefix = 'fumadocs-asyncapi-', name: KeyName) {
return prefix + name;
}
@@ -0,0 +1,33 @@
'use client';
import {
DynamicCodeBlock,
type DynamicCodeblockProps,
} from 'fumadocs-ui/components/dynamic-codeblock.core';
import { useRenderContext } from '../contexts/api';
import { createContext, type ReactNode, use } from 'react';
import type { ShikiFactory } from 'fumadocs-core/highlight/shiki';
const CodeBlockContext = createContext<ShikiFactory | null>(null);
export function ClientCodeBlock(props: Omit<DynamicCodeblockProps, 'highlighter' | 'options'>) {
const { shikiOptions, components: { CodeBlock: Comp } = {} } = useRenderContext();
if (Comp) return <Comp lang={props.lang} code={props.code} />;
const ctx = use(CodeBlockContext);
if (!ctx)
throw new Error(
'Missing Shiki context, please wrap your <AsyncAPIPage /> component under <ClientCodeBlockProvider />',
);
return <DynamicCodeBlock highlighter={() => ctx.getOrInit()} options={shikiOptions} {...props} />;
}
export function ClientCodeBlockProvider({
factory,
children,
}: {
factory: ShikiFactory;
children: ReactNode;
}) {
return <CodeBlockContext value={factory}>{children}</CodeBlockContext>;
}
@@ -0,0 +1,17 @@
'use client';
import type { ComponentProps } from 'react';
import { Heading as BaseHeading } from 'fumadocs-ui/components/heading';
import { useRenderContext } from '../contexts/api';
import { useAnchorId } from '@fumadocs/api-docs/auto-anchor/client';
export function Heading({
id: _id,
depth,
...props
}: ComponentProps<'h1'> & { id: string; depth: number }) {
const id = useAnchorId([_id]);
const Component = useRenderContext().components?.Heading;
if (Component) return <Component id={id} depth={depth} {...props} />;
return <BaseHeading id={id} as={`h${depth}` as `h1`} {...props} />;
}
@@ -0,0 +1,8 @@
import { useMemo } from 'react';
import { useRenderContext } from '../contexts/api';
export function Markdown({ md }: { md: string }) {
const { _default_processMarkdown: processMarkdown } = useRenderContext();
return useMemo(() => processMarkdown(md), [processMarkdown, md]);
}
@@ -0,0 +1,51 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { HTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
const badgeVariants = cva('font-mono font-medium', {
variants: {
color: {
green: 'text-green-600 dark:text-green-400',
yellow: 'text-yellow-600 dark:text-yellow-400',
red: 'text-red-600 dark:text-red-400',
blue: 'text-blue-600 dark:text-blue-400',
orange: 'text-orange-600 dark:text-orange-400',
},
},
});
function getActionColor(action: string): VariantProps<typeof badgeVariants>['color'] {
switch (action.toLowerCase()) {
case 'send':
return 'blue';
case 'receive':
return 'green';
default:
return 'orange';
}
}
export function Badge({
className,
color,
...props
}: Omit<HTMLAttributes<HTMLSpanElement>, 'color'> & VariantProps<typeof badgeVariants>) {
return (
<span className={cn(badgeVariants({ color }), className)} {...props}>
{props.children}
</span>
);
}
export function ActionLabel({
children,
...props
}: Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
children: string;
}) {
return (
<Badge {...props} color={getActionColor(children)}>
{children.toUpperCase()}
</Badge>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { createContext, type ReactNode, use, useEffect, useMemo, useState } from 'react';
import type { RenderContext, ServerObject } from '@/types';
import type { NoReference } from '@fumadocs/api-docs/schema';
import { useStorageKey } from '../client/storage-key';
import { resolveServerUrl } from '@/utils/operation';
interface ServerContextType {
servers?: ServerObject[];
server: SelectedServer | null;
setServer: (value: string) => void;
setServerVariables: (value: Record<string, string>) => void;
}
interface SelectedServer {
url: string;
name?: string;
variables: Record<string, string>;
}
const Context = createContext<RenderContext | null>(null);
const ServerContext = createContext<ServerContextType | null>(null);
export function useRenderContext(): RenderContext {
const ctx = use(Context);
if (!ctx) throw new Error('Component must be used under <ApiProvider />');
return ctx;
}
export function RenderContextProvider({
children,
ctx,
}: {
ctx: RenderContext;
children: ReactNode;
}) {
return <Context value={ctx}>{children}</Context>;
}
export function ServerProvider({
servers,
children,
}: {
servers?: ServerObject[];
children: ReactNode;
}) {
const storageKey = useStorageKey().of('server-url');
const [server, setServer] = useState<SelectedServer | null>(() => {
if (!servers || servers.length === 0) return null;
const defaultItem = servers[0];
const variables = getDefaultValues(defaultItem);
return {
name: defaultItem.description,
url: resolveServerUrl(defaultItem as NoReference<ServerObject>, variables),
variables,
};
});
useEffect(() => {
const cached = localStorage.getItem(storageKey);
if (!cached) return;
try {
const obj: unknown = JSON.parse(cached);
if (
typeof obj === 'object' &&
obj !== null &&
'url' in obj &&
typeof obj.url === 'string' &&
'variables' in obj &&
typeof obj.variables === 'object' &&
obj.variables !== null
) {
setServer(obj as SelectedServer);
}
} catch {
// ignore
}
}, [storageKey]);
return (
<ServerContext
value={useMemo(
() => ({
servers,
server,
setServerVariables(variables) {
setServer((prev) => {
if (!prev || !servers) return null;
const selected = servers.find(
(item) =>
resolveServerUrl(item as NoReference<ServerObject>, variables) === prev.url,
);
const target = selected ?? servers[0];
const updated = {
...prev,
variables,
url: resolveServerUrl(target as NoReference<ServerObject>, variables),
};
localStorage.setItem(storageKey, JSON.stringify(updated));
return updated;
});
},
setServer(value) {
const obj = servers?.find(
(item) => resolveServerUrl(item as NoReference<ServerObject>) === value,
);
if (!obj) return;
const result: SelectedServer = {
name: obj.description,
url: value,
variables: getDefaultValues(obj),
};
localStorage.setItem(storageKey, JSON.stringify(result));
setServer(result);
},
}),
[server, servers, storageKey],
)}
>
{children}
</ServerContext>
);
}
function getDefaultValues(server: ServerObject): Record<string, string> {
const out: Record<string, string> = {};
if (!server.variables) return out;
for (const [k, v] of Object.entries(server.variables)) {
if (typeof v === 'object' && v !== null && 'default' in v && v.default !== undefined) {
out[k] = String(v.default);
}
}
return out;
}
+255
View File
@@ -0,0 +1,255 @@
'use client';
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import type {
AsyncAPIObject,
Awaitable,
ChannelObject,
OperationObject,
RenderContext,
} from '@/types';
import {
Children,
type ComponentProps,
type ReactElement,
useMemo,
type FC,
type ReactNode,
} from 'react';
import { remarkGfm } from 'fumadocs-core/mdx-plugins/remark-gfm';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { remark } from 'remark';
import remarkRehype from 'remark-rehype';
import { toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as JsxRuntime from 'react/jsx-runtime';
import { PageContent } from './api-page';
import { defaultShikiFactory } from 'fumadocs-core/highlight/shiki/full';
import { compile } from '@fumari/json-schema-ts';
import { ClientCodeBlock, ClientCodeBlockProvider } from './components/codeblock';
import { dereferenceBundledDocument } from '@/utils/document/dereference';
import type { ShikiFactory } from 'fumadocs-core/highlight/shiki';
import type { JSONSchema } from 'json-schema-typed';
import type { CodeToHastOptionsCommon, CodeOptionsThemes, BundledTheme } from 'shiki';
import type { GeneratedPageProps, OperationItem } from '@/utils/pages/builder';
import { ParsedSchema } from '@/utils/schema';
import { Markdown } from './components/markdown';
import { TranslationsProvider } from '@fumadocs/api-docs/i18n';
import { Schema } from '@fumadocs/api-docs/components/schema';
import { RenderContextProvider } from './contexts/api';
import type { NoReference } from '@fumadocs/api-docs/schema';
export interface GenerateTypeScriptDefinitionsContext {
readOnly: boolean;
writeOnly: boolean;
ctx: RenderContext;
}
export interface CreateAsyncAPIPageOptions {
generateTypeScriptDefinitions?:
| ((
schema: JSONSchema,
ctx: GenerateTypeScriptDefinitionsContext,
) => Awaitable<string | undefined>)
| false;
shiki?: ShikiFactory;
shikiOptions?: Omit<CodeToHastOptionsCommon, 'lang'> & CodeOptionsThemes<BundledTheme>;
content?: {
renderPageLayout?: (
slots: {
operations?: {
item: OperationItem;
children: ReactNode;
}[];
},
ctx: RenderContext,
) => ReactNode;
renderOperationLayout?: (
slots: {
header: ReactNode;
description: ReactNode;
channel: ReactNode;
authSchemes: ReactNode;
parameters: ReactNode;
messages: ReactNode;
reply: ReactNode;
traits: ReactNode;
bindings: ReactNode;
messageExamples: ReactNode;
},
context: {
operation: NoReference<OperationObject>;
action: 'send' | 'receive';
channel: NoReference<ChannelObject>;
ctx: RenderContext;
},
) => ReactNode;
renderAPIExampleLayout?: (
slots: {
selector: ReactNode;
usageTabs: ReactNode;
responseTabs: ReactNode;
},
ctx: RenderContext,
) => ReactNode;
renderAPIExampleUsageTabs?: (
items: import('@/utils/get-example-messages').ExampleMessageItem[],
ctx: RenderContext,
) => ReactNode;
};
schemaUI?: {
render?: (
options: {
root: ParsedSchema;
readOnly?: boolean;
writeOnly?: boolean;
},
ctx: RenderContext,
) => ReactNode;
showExample?: boolean;
};
components?: {
Heading?: FC<ComponentProps<'h1'> & { id: string; depth: number }>;
CodeBlock?: FC<{ lang: string; code: string }>;
Markdown?: FC<{ md: string }>;
};
storageKeyPrefix?: string;
}
export type AsyncAPIPageProps = AsyncAPIPageProps_Spec | AsyncAPIPageProps_Preloaded;
export type AsyncAPIPageProps_Spec = Omit<GeneratedPageProps, 'document'> & {
payload: {
bundled: AsyncAPIObject;
proxyUrl?: string;
};
};
export type AsyncAPIPageProps_Preloaded = GeneratedPageProps & {
preloaded: {
docs: Record<string, AsyncAPIObject>;
proxyUrl?: string;
};
};
export function createAsyncAPIPage({
shiki = defaultShikiFactory,
shikiOptions = { themes: { light: 'github-light', dark: 'github-dark' } },
schemaUI: schemaUIOptions,
generateTypeScriptDefinitions = (schema, ctx) => {
if (typeof schema !== 'object') return;
try {
return compile(schema, {
name: 'Message',
readOnly: ctx.readOnly,
writeOnly: ctx.writeOnly,
getSchemaId: ctx.ctx.schema.getRawRef,
});
} catch (e) {
console.warn('Failed to generate typescript schema:', e);
}
},
...options
}: CreateAsyncAPIPageOptions = {}): FC<AsyncAPIPageProps> {
let processor: ReturnType<typeof createMarkdownProcessor>;
function createMarkdownProcessor() {
const mdxComponents = {
...defaultMdxComponents,
img: undefined,
pre: MarkdownPre,
};
function rehypeReact(this: any) {
this.compiler = (tree: any, file: any) => {
return toJsxRuntime(tree, {
development: false,
filePath: file.path,
...JsxRuntime,
components: mdxComponents,
});
};
}
return remark().use(remarkGfm).use(remarkRehype).use(rehypeReact);
}
return function AsyncAPIPage(props) {
let doc: AsyncAPIObject;
let proxyUrl: string | undefined;
if ('preloaded' in props) {
doc = props.preloaded.docs[props.document];
if (!doc)
throw new Error(
`[Fumadocs AsyncAPI] the document ${props.document} is not preloaded, make sure to pass the "preloaded" prop to <AsyncAPIPage />`,
);
proxyUrl = props.preloaded.proxyUrl;
} else {
doc = props.payload.bundled;
proxyUrl = props.payload.proxyUrl;
}
const processed = useMemo(() => dereferenceBundledDocument(doc), [doc]);
const ctx: RenderContext = useMemo(() => {
function renderMarkdown(md: string) {
return <Markdown md={md} />;
}
function resolver(v: ParsedSchema) {
return {
dereferenced: v,
$ref: typeof v === 'object' ? processed.getRawRef(v) : undefined,
};
}
return {
schema: processed,
proxyUrl,
shiki,
shikiOptions,
generateTypeScriptDefinitions,
SchemaUI(props) {
if (schemaUIOptions?.render) return schemaUIOptions.render(props, ctx);
return (
<Schema
{...props}
showExample={props.showExample ?? schemaUIOptions?.showExample}
resolver={resolver}
renderMarkdown={renderMarkdown}
/>
);
},
...options,
_default_processMarkdown(md) {
processor ??= createMarkdownProcessor();
return processor.processSync(md).result as ReactNode;
},
storageKeyPrefix: options.storageKeyPrefix ?? 'fumadocs-asyncapi-',
};
}, [proxyUrl, processed]);
return (
<TranslationsProvider namespace="asyncapi">
<ClientCodeBlockProvider factory={shiki}>
<RenderContextProvider ctx={ctx}>
<PageContent {...props} />
</RenderContextProvider>
</ClientCodeBlockProvider>
</TranslationsProvider>
);
};
}
function MarkdownPre(props: ComponentProps<'pre'>) {
const code = Children.only(props.children) as ReactElement;
const codeProps = code.props as ComponentProps<'code'>;
const content = codeProps.children;
if (typeof content !== 'string') return null;
const lang =
codeProps.className
?.split(' ')
.find((v) => v.startsWith('language-'))
?.slice('language-'.length) ?? 'text';
return <ClientCodeBlock lang={lang} code={content.trimEnd()} />;
}
@@ -0,0 +1,546 @@
import { type ComponentProps, Fragment, useMemo, type ReactNode } from 'react';
import type {
ChannelObject,
MessageObject,
OperationObject,
MultiFormatSchemaObject,
OperationReplyObject,
ParameterObject,
SecuritySchemeObject,
ServerObject,
} from '@/types';
import { UsageTabs } from '@/ui/operation/usage-tabs';
import { ActionLabel } from '@/ui/components/method-label';
import { I18nLabel } from '@/ui/client/i18n';
import {
AccordionContent,
AccordionHeader,
AccordionItem,
Accordions,
AccordionTrigger,
} from '@fumadocs/api-docs/components/accordion';
import { cn } from '@/utils/cn';
import { getExampleMessages } from '../../utils/get-example-messages';
import { SelectTabs, SelectTabTrigger, SelectTab } from '@fumadocs/api-docs/components/select-tab';
import { AnchorSection } from '@fumadocs/api-docs/auto-anchor/client';
import { Heading } from '@/ui/components/heading';
import { Markdown } from '../components/markdown';
import { ServerProvider, useRenderContext } from '../contexts/api';
import type { NoReference } from '@fumadocs/api-docs/schema';
import { getChannelAddress, getOperationDisplayName, resolveSchema } from '@/utils/operation';
import { ClientCodeBlock } from '../components/codeblock';
import { dereferenceShallow } from '@fumadocs/api-docs/schema/dereference';
export function Operation({
id,
action,
operation,
channel,
messages,
reply,
showTitle,
showDescription,
headingLevel = 2,
}: {
id: string;
action: 'send' | 'receive';
operation: NoReference<OperationObject>;
channel: NoReference<ChannelObject>;
messages: NoReference<MessageObject>[];
reply?: NoReference<OperationReplyObject>;
showTitle?: boolean;
showDescription?: boolean;
headingLevel?: number;
}) {
const ctx = useRenderContext();
const {
schema: { dereferenced },
} = ctx;
const operationDescription = operation.description ?? channel.description;
const descriptionNode = showDescription && operationDescription && (
<Markdown md={operationDescription} />
);
const exampleMessages = useMemo(() => getExampleMessages({ messages }), [messages]);
let headNode: ReactNode = null;
if (showTitle) {
const title = getOperationDisplayName(id, operation, channel);
headNode = (
<div className="flex gap-2 items-center justify-between">
<Heading id={title} depth={headingLevel} className="my-0!">
{title}
</Heading>
<ActionLabel className="text-xs">{action}</ActionLabel>
</div>
);
headingLevel++;
}
const channelNode = <ChannelSection channel={channel} headingLevel={headingLevel} />;
const parametersNode = channel.parameters ? (
<ParametersSection parameters={channel.parameters} headingLevel={headingLevel} />
) : null;
const messagesNode = <MessagesSection messages={messages} headingLevel={headingLevel} />;
const replyNode = reply ? <ReplySection reply={reply} headingLevel={headingLevel} /> : null;
const bindingsNode = (
<>
<BindingsSection
bindings={operation.bindings}
id="operation-bindings"
headingLevel={headingLevel}
/>
<BindingsSection
bindings={channel.bindings}
id="channel-bindings"
headingLevel={headingLevel}
title={<I18nLabel label="titleChannel" />}
/>
</>
);
const traits =
operation.traits
?.map((trait) => dereferenceShallow(trait, dereferenced))
.filter((trait): trait is NoReference<import('@/types/asyncapi-3').OperationTraitObject> =>
Boolean(trait),
) ?? [];
const traitsNode =
traits.length > 0 ? <TraitsSection traits={traits} headingLevel={headingLevel} /> : null;
const securitySchemes = (operation.security ?? [])
.map((scheme) => dereferenceShallow(scheme, dereferenced))
.filter((scheme): scheme is SecuritySchemeObject => Boolean(scheme));
let authNode: ReactNode = null;
if (securitySchemes.length > 0) {
const items = securitySchemes.map((scheme, i) => ({
value: String(i),
label: <code className="text-xs truncate">{scheme.name || scheme.type}</code>,
}));
authNode = (
<SelectTabs defaultValue={items[0].value}>
<div className="flex items-start justify-between gap-2 mt-10">
<Heading id="authorization" depth={headingLevel} className="my-0!">
<I18nLabel label="authorization" />
</Heading>
{items.length > 1 ? (
<SelectTabTrigger items={items} />
) : (
<div className="not-prose">{items[0].label}</div>
)}
</div>
{securitySchemes.map((scheme, i) => (
<SelectTab key={i} value={items[i].value}>
<AuthScheme scheme={scheme} scopes={scheme.scopes ?? []} />
</SelectTab>
))}
</SelectTabs>
);
}
let { renderOperationLayout } = ctx.content ?? {};
renderOperationLayout ??= (slots) => {
return (
<div className="flex flex-col gap-x-6 gap-y-4 @4xl:flex-row @4xl:items-start">
<div className="min-w-0 flex-1">
{slots.header}
{slots.description}
{slots.channel}
{slots.authSchemes}
{slots.parameters}
{slots.messages}
{slots.reply}
{slots.traits}
{slots.bindings}
</div>
<div className="@4xl:sticky @4xl:top-[calc(var(--fd-docs-row-1,2rem)+1rem)] @4xl:w-[400px]">
{slots.messageExamples}
</div>
</div>
);
};
let content = renderOperationLayout(
{
header: headNode,
description: descriptionNode,
channel: channelNode,
authSchemes: authNode,
parameters: parametersNode,
messages: messagesNode,
reply: replyNode,
traits: traitsNode,
bindings: bindingsNode,
messageExamples: (
<UsageTabs operation={operation} messages={messages} examples={exampleMessages} />
),
},
{
operation,
action,
channel,
ctx,
},
);
const servers = (operation as { servers?: ServerObject[] }).servers ?? channel.servers;
if (servers && servers.length > 0) {
content = <ServerProvider servers={servers as ServerObject[]}>{content}</ServerProvider>;
}
return content;
}
function ChannelSection({
channel,
headingLevel,
}: {
channel: NoReference<ChannelObject>;
headingLevel: number;
}) {
const address = getChannelAddress(channel);
if (!address && !channel.summary && !channel.title) return null;
return (
<>
<Heading id="channel" depth={headingLevel} className="mt-10">
<I18nLabel label="titleChannel" />
</Heading>
<div className="not-prose text-sm border rounded-xl p-3 flex flex-col gap-2">
{channel.title && <p className="font-medium">{channel.title}</p>}
{channel.summary && <p className="text-fd-muted-foreground">{channel.summary}</p>}
{address && (
<p>
<I18nLabel label="channelAddress" />: <code>{address}</code>
</p>
)}
</div>
</>
);
}
function ParametersSection({
parameters,
headingLevel,
}: {
parameters: Record<string, NoReference<ParameterObject>>;
headingLevel: number;
}) {
const ctx = useRenderContext();
const entries = Object.entries(parameters);
if (entries.length === 0) return null;
return (
<>
<Heading id="parameters" depth={headingLevel} className="mt-10">
<I18nLabel label="titleParameters" />
</Heading>
<AnchorSection segments={['parameters']}>
<div className="flex flex-col">
{entries.map(([name, param]) => (
<ctx.SchemaUI
key={name}
client={{
name,
required: false,
}}
root={{
type: 'string',
description: param.description,
enum: param.enum,
default: param.default,
}}
/>
))}
</div>
</AnchorSection>
</>
);
}
function MessagesSection({
messages,
headingLevel,
}: {
messages: NoReference<MessageObject>[];
headingLevel: number;
}) {
const ctx = useRenderContext();
if (messages.length === 0) return null;
return (
<>
<Heading id="messages" depth={headingLevel} className="mt-10">
<I18nLabel label="titleMessages" />
</Heading>
<Accordions type="multiple">
{messages.map((message, index) => {
const name = message.name || message.title || `message-${index + 1}`;
const headers = resolveSchema(message.headers as MultiFormatSchemaObject);
const payload =
message.payload ?? resolveSchema(message.payload as MultiFormatSchemaObject);
return (
<AccordionItem key={name} value={name} anchorSegments={['messages', name]}>
<AccordionHeader>
<AccordionTrigger>{message.title || name}</AccordionTrigger>
</AccordionHeader>
<AccordionContent className="ps-4.5 pe-3 border rounded-xl">
{message.description && <Markdown md={message.description} />}
{message.contentType && (
<p className="text-xs text-fd-muted-foreground not-prose">
Content-Type: <code>{message.contentType}</code>
</p>
)}
{headers && (
<>
<Heading id={`${name}-headers`} depth={headingLevel + 1}>
<I18nLabel label="titleHeaders" />
</Heading>
<ctx.SchemaUI client={{ name: 'headers' }} root={headers as never} />
</>
)}
{payload && (
<>
<Heading id={`${name}-payload`} depth={headingLevel + 1}>
<I18nLabel label="titlePayload" />
</Heading>
<ctx.SchemaUI
client={{ name: 'payload', as: 'body' }}
root={payload as never}
/>
</>
)}
{message.correlationId && (
<CorrelationIdSection correlationId={message.correlationId} />
)}
<BindingsSection
bindings={message.bindings}
id={`${name}-bindings`}
headingLevel={headingLevel + 1}
/>
</AccordionContent>
</AccordionItem>
);
})}
</Accordions>
</>
);
}
function ReplySection({
reply,
headingLevel,
}: {
reply: NoReference<OperationReplyObject>;
headingLevel: number;
}) {
const ctx = useRenderContext();
return (
<>
<Heading id="reply" depth={headingLevel} className="mt-10">
<I18nLabel label="titleReply" />
</Heading>
<div className="border rounded-xl p-3 not-prose text-sm flex flex-col gap-3">
{reply.address && (
<p>
Address: <code>{reply.address.location}</code>
{reply.address.description && (
<span className="text-fd-muted-foreground"> — {reply.address.description}</span>
)}
</p>
)}
{reply.messages?.map((messageRef, index) => {
const message = dereferenceShallow(messageRef, ctx.schema.dereferenced);
if (!message) return null;
const payload = message.payload ?? resolveSchema(message.payload as never);
return (
<Fragment key={index}>
<p className="font-medium">{message.title || message.name || `Reply ${index + 1}`}</p>
{payload && (
<ctx.SchemaUI client={{ name: 'reply-payload' }} root={payload as never} />
)}
</Fragment>
);
})}
</div>
</>
);
}
function TraitsSection({
traits,
headingLevel,
}: {
traits: NoReference<import('@/types/asyncapi-3').OperationTraitObject>[];
headingLevel: number;
}) {
return (
<>
<Heading id="traits" depth={headingLevel} className="mt-10">
<I18nLabel label="titleTraits" />
</Heading>
<Accordions type="multiple">
{traits.map((trait, index) => (
<AccordionItem key={index} value={String(index)}>
<AccordionHeader>
<AccordionTrigger>
{trait.title || trait.summary || `Trait ${index + 1}`}
</AccordionTrigger>
</AccordionHeader>
<AccordionContent>
{trait.description && <Markdown md={trait.description} />}
<BindingsSection
bindings={trait.bindings}
id={`trait-${index}-bindings`}
headingLevel={headingLevel + 1}
/>
</AccordionContent>
</AccordionItem>
))}
</Accordions>
</>
);
}
function BindingsSection({
bindings,
id,
headingLevel,
title,
}: {
bindings?: Record<string, unknown>;
id: string;
headingLevel: number;
title?: ReactNode;
}) {
if (!bindings) return null;
const entries = Object.entries(bindings).filter(([, value]) => value);
if (entries.length === 0) return null;
return (
<>
<Heading id={id} depth={headingLevel}>
{title ?? <I18nLabel label="titleBindings" />}
</Heading>
<Accordions type="multiple">
{entries.map(([protocol, binding]) => (
<AccordionItem key={protocol} value={protocol}>
<AccordionHeader>
<AccordionTrigger>{protocol}</AccordionTrigger>
</AccordionHeader>
<AccordionContent>
<ClientCodeBlock lang="json" code={JSON.stringify(binding, null, 2)} />
</AccordionContent>
</AccordionItem>
))}
</Accordions>
</>
);
}
function CorrelationIdSection({
correlationId,
}: {
correlationId: NoReference<import('@/types/asyncapi-3').CorrelationIDObject>;
}) {
return (
<div className="text-sm not-prose mt-3">
<p className="font-medium">
<I18nLabel label="titleCorrelationId" />
</p>
<p>
Location: <code>{correlationId.location}</code>
</p>
{correlationId.description && (
<p className="text-fd-muted-foreground">{correlationId.description}</p>
)}
</div>
);
}
function AuthScheme({ scheme, scopes }: { scheme: SecuritySchemeObject; scopes: string[] }) {
if (scheme.type === 'http' || scheme.type === 'oauth2') {
return (
<AuthProperty
name={<I18nLabel label="authorization" />}
type={
scheme.type === 'http' && scheme.scheme === 'basic' ? (
<I18nLabel label="authBasicTokenExample" />
) : (
<I18nLabel label="authBearerTokenExample" />
)
}
scopes={scopes}
>
{scheme.description && <Markdown md={scheme.description} />}
</AuthProperty>
);
}
if (scheme.type === 'apiKey' || scheme.type === 'httpApiKey') {
return (
<AuthProperty name={scheme.name!} type="<token>" scopes={scopes}>
{scheme.description && <Markdown md={scheme.description} />}
{scheme.in && (
<p>
<I18nLabel label="authTokenIn" />: <code>{scheme.in}</code>
</p>
)}
</AuthProperty>
);
}
if (scheme.type === 'openIdConnect') {
return (
<AuthProperty name={<I18nLabel label="openIdConnect" />} type="<token>" scopes={scopes}>
{scheme.description && <Markdown md={scheme.description} />}
</AuthProperty>
);
}
return (
<AuthProperty name={scheme.type} type="<credentials>" scopes={scopes}>
{scheme.description && <Markdown md={scheme.description} />}
</AuthProperty>
);
}
function AuthProperty({
name,
type,
scopes = [],
className,
...props
}: ComponentProps<'div'> & {
name: ReactNode;
type: ReactNode;
scopes?: string[];
}) {
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>
</div>
<div className="prose-no-margin pt-2.5 empty:hidden">
{props.children}
{scopes.length > 0 && (
<p>
<I18nLabel label="authScope" />: <code>{scopes.join(', ')}</code>
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,109 @@
'use client';
import type { MessageObject, OperationObject } from '@/types';
import {
CodeBlockTab,
CodeBlockTabs,
CodeBlockTabsList,
CodeBlockTabsTrigger,
} from 'fumadocs-ui/components/codeblock';
import { NoReference } from '@fumadocs/api-docs/schema';
import { useRenderContext } from '@/ui/contexts/api';
import { useMemo } from 'react';
import { ClientCodeBlock } from '@/ui/components/codeblock';
import type { ExampleMessageItem } from '@/utils/get-example-messages';
import { I18nLabel } from '@/ui/client/i18n';
import { Heading } from '@/ui/components/heading';
export function UsageTabs({
operation,
messages,
examples,
}: {
operation: NoReference<OperationObject>;
messages: NoReference<MessageObject>[];
examples: ExampleMessageItem[];
}) {
const ctx = useRenderContext();
let { renderAPIExampleUsageTabs, renderAPIExampleLayout } = ctx.content ?? {};
renderAPIExampleLayout ??= (slots) => {
return <div className="prose-no-margin">{slots.usageTabs}</div>;
};
renderAPIExampleUsageTabs ??= (items, _ctx) => {
if (items.length === 0) return null;
return (
<CodeBlockTabs groupId="fumadocs_asyncapi_messages" defaultValue={items[0].id}>
<CodeBlockTabsList>
{items.map((item) => (
<CodeBlockTabsTrigger key={item.id} value={item.id}>
{item.name}
</CodeBlockTabsTrigger>
))}
</CodeBlockTabsList>
{items.map((item) => (
<CodeBlockTab key={item.id} value={item.id}>
<MessageExampleContent item={item} />
</CodeBlockTab>
))}
</CodeBlockTabs>
);
};
const content =
renderAPIExampleLayout?.(
{
selector: null,
usageTabs: renderAPIExampleUsageTabs(examples, ctx),
responseTabs: null,
},
ctx,
) ?? renderAPIExampleUsageTabs(examples, ctx);
if (!content) return null;
return (
<>
<Heading id="message-examples" depth={3} className="my-0!">
<I18nLabel label="titleMessages" />
</Heading>
{operation.description && messages.length > 0 ? null : null}
{content}
</>
);
}
function MessageExampleContent({ item }: { item: ExampleMessageItem }) {
const payload = useMemo(() => {
if (item.payload === undefined) return;
return JSON.stringify(item.payload, null, 2);
}, [item.payload]);
const headers = useMemo(() => {
if (item.headers === undefined) return;
return JSON.stringify(item.headers, null, 2);
}, [item.headers]);
return (
<div className="flex flex-col gap-3">
{item.description && <p className="text-sm text-fd-muted-foreground">{item.description}</p>}
{headers && (
<div>
<p className="text-xs font-medium mb-1">
<I18nLabel label="titleHeaders" />
</p>
<ClientCodeBlock lang="json" code={headers} />
</div>
)}
{payload && (
<div>
<p className="text-xs font-medium mb-1">
<I18nLabel label="titlePayload" />
</p>
<ClientCodeBlock lang="json" code={payload} />
</div>
)}
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { twMerge as cn } from 'tailwind-merge';
@@ -0,0 +1,34 @@
import type { JSONSchema } from 'json-schema-typed/draft-2020-12';
import type { AsyncAPIObject } from '@/types';
import { dereferenceSync } from '@fumadocs/api-docs/schema/dereference';
import type { NoReference } from '@fumadocs/api-docs/schema';
export interface DereferencedDocument {
/**
* dereferenced document
*/
dereferenced: NoReference<AsyncAPIObject>;
/**
* Get raw $ref from dereferenced object
*/
getRawRef: (obj: object) => string | undefined;
bundled: AsyncAPIObject;
}
export function dereferenceBundledDocument(bundled: AsyncAPIObject): DereferencedDocument {
const dereferenceMap = new Map<object, string>();
return {
bundled,
dereferenced: dereferenceSync(bundled as JSONSchema, {
setOriginalRef(schema, ref) {
dereferenceMap.set(schema as object, ref);
},
}) as NoReference<AsyncAPIObject>,
getRawRef(obj: object) {
return dereferenceMap.get(obj);
},
};
}
@@ -0,0 +1,22 @@
import type { AsyncAPIObject } from '@/types';
import { bundle } from '@fumadocs/api-docs/schema/bundle';
/**
* Process input document to a Fumadocs AsyncAPI compatible format.
*/
export async function loadDocument(input: string | AsyncAPIObject): Promise<{
bundled: AsyncAPIObject;
}> {
try {
const bundled = await bundle<AsyncAPIObject>(input);
if (!bundled.asyncapi) {
throw new Error('Missing required `asyncapi` field.');
}
return { bundled };
} catch (e) {
throw new Error(`[AsyncAPI] Failed to resolve input: ${input}`, {
cause: e,
});
}
}
@@ -0,0 +1,65 @@
import type { MessageObject, MultiFormatSchemaObject } from '@/types';
import { pickExample, pickMessageExample } from '@/utils/schema';
import type { NoReference } from '@fumadocs/api-docs/schema';
import { sample } from '@fumadocs/api-docs/schema/sample';
import { resolveSchema } from '@/utils/operation';
export interface ExampleMessageItem {
id: string;
name: string;
description?: string;
headers?: unknown;
payload?: unknown;
}
export function getExampleMessages({
messages,
}: {
messages: NoReference<MessageObject>[];
}): ExampleMessageItem[] {
const result: ExampleMessageItem[] = [];
for (const message of messages) {
const messageName = message.name || message.title || 'message';
if (message.examples && message.examples.length > 0) {
for (const [index, example] of message.examples.entries()) {
const picked = pickMessageExample(example);
result.push({
id: `${messageName}-${example.name || index}`,
name: example.name || example.summary || `${messageName} ${index + 1}`,
description: example.summary || message.description,
headers: picked.headers,
payload: picked.payload,
});
}
continue;
}
const headersSchema = resolveSchema(message.headers as MultiFormatSchemaObject);
const payload = message.payload ?? resolveSchema(message.payload as MultiFormatSchemaObject);
result.push({
id: messageName,
name: message.title || messageName,
description: message.description,
headers:
headersSchema && typeof headersSchema === 'object'
? sample(headersSchema as object, { skipNonRequired: true })
: pickExample(message as never),
payload:
payload && typeof payload === 'object'
? sample(payload as object, { skipNonRequired: true })
: pickExample(message as never),
});
}
if (result.length === 0) {
result.push({
id: '_default',
name: 'Default',
});
}
return result;
}
+136
View File
@@ -0,0 +1,136 @@
import type {
AsyncAPIObject,
AsyncAPISchemaObject,
ChannelObject,
MessageObject,
MultiFormatSchemaObject,
OperationObject,
OperationReplyObject,
ServerObject,
TagObject,
} from '@/types';
import { dereferenceShallow } from '@fumadocs/api-docs/schema/dereference';
import type { NoReference } from '@fumadocs/api-docs/schema';
import { idToTitle } from '@fumadocs/api-docs/utils/id-to-title';
interface ResolvedOperation {
id: string;
operation: NoReference<OperationObject>;
channel: NoReference<ChannelObject>;
messages: NoReference<MessageObject>[];
reply?: NoReference<OperationReplyObject>;
}
export function resolveSchema(schema?: MultiFormatSchemaObject): AsyncAPISchemaObject | undefined {
if (!schema) return;
if (typeof schema === 'object' && schema !== null && 'schema' in schema) {
return schema.schema;
}
return schema;
}
export function getChannelAddress(channel: NoReference<ChannelObject>): string | undefined {
if (channel.address === null) return;
return channel.address ?? undefined;
}
export function resolveServerUrl(
server: NoReference<ServerObject>,
variables: Record<string, string> = {},
): string {
let host = server.host;
let pathname = server.pathname ?? '';
for (const [key, value] of Object.entries(variables)) {
const token = `{${key}}`;
host = host.replaceAll(token, value);
pathname = pathname.replaceAll(token, value);
}
if (pathname && !pathname.startsWith('/')) pathname = `/${pathname}`;
return `${server.protocol}://${host}${pathname}`;
}
export function resolveOperation(id: string, doc: AsyncAPIObject): ResolvedOperation | undefined {
const operation = dereferenceShallow(doc.operations?.[id], doc);
if (!operation) return;
const channel = dereferenceShallow(operation.channel, doc);
if (!channel) return;
const messages = getOperationMessages(
operation as NoReference<OperationObject>,
channel as NoReference<ChannelObject>,
doc,
);
const reply = operation.reply
? (dereferenceShallow(operation.reply, doc) as NoReference<OperationReplyObject>)
: undefined;
return {
id,
operation: operation as NoReference<OperationObject>,
channel: channel as NoReference<ChannelObject>,
messages,
reply,
};
}
export function getOperationMessages(
operation: NoReference<OperationObject>,
channel: NoReference<ChannelObject>,
doc: AsyncAPIObject,
): NoReference<MessageObject>[] {
if (operation.messages && operation.messages.length > 0) {
return operation.messages
.map((message) => dereferenceShallow(message, doc))
.filter(Boolean) as NoReference<MessageObject>[];
}
if (!channel.messages) return [];
const messages: NoReference<MessageObject>[] = [];
for (const [name, message] of Object.entries(channel.messages)) {
const resolved = dereferenceShallow(message, doc);
if (!resolved) continue;
messages.push({
...resolved,
name: resolved.name ?? name,
});
}
return messages;
}
export function getOperationDisplayName(
id: string,
operation: NoReference<OperationObject>,
channel: NoReference<ChannelObject>,
): string {
return operation.title || operation.summary || channel.title || channel.summary || idToTitle(id);
}
export function collectDocumentTags(doc: AsyncAPIObject): NoReference<TagObject>[] {
const tags = new Map<string, NoReference<TagObject>>();
for (const tag of doc.info.tags ?? []) {
const resolved = dereferenceShallow(tag, doc) as NoReference<TagObject> | undefined;
if (resolved?.name) tags.set(resolved.name, resolved);
}
for (const operation of Object.values(doc.operations ?? {})) {
const resolved = dereferenceShallow(operation, doc);
for (const tag of resolved?.tags ?? []) {
const resolvedTag = dereferenceShallow(tag, doc) as NoReference<TagObject> | undefined;
if (resolvedTag?.name) tags.set(resolvedTag.name, resolvedTag);
}
}
return [...tags.values()];
}
export function getOperationTags(operation: OperationObject, doc: AsyncAPIObject): string[] {
return (operation.tags ?? [])
.map((tag) => (dereferenceShallow(tag, doc) as { name?: string } | undefined)?.name)
.filter((name): name is string => Boolean(name));
}
@@ -0,0 +1,188 @@
import type { AsyncAPIObject, ChannelObject, OperationObject, TagObject } from '@/types';
import type { NoReference } from '@fumadocs/api-docs/schema';
import { getTagDisplayName } from '@/utils/schema';
import { dereferenceShallow } from '@fumadocs/api-docs/schema/dereference';
import {
collectDocumentTags,
getOperationDisplayName,
getOperationTags,
resolveOperation,
} from '@/utils/operation';
import type { NoReferenceSwallow } from '@fumadocs/api-docs/schema';
interface BaseEntry {
path: string;
schemaId: string;
info: {
title: string;
description?: string;
deprecated?: boolean;
};
}
export interface OperationOutput extends BaseEntry {
type: 'operation';
item: OperationItem;
}
export interface PageOutput extends BaseEntry {
type: 'page';
operations: OperationItem[];
tag?: TagObject;
}
export interface OutputGroup extends BaseEntry {
type: 'group';
entries: OutputEntry[];
tag?: TagObject;
}
export interface OperationItem {
/**
* the operation id in `operations`
*/
id: string;
/**
* the action of operation
*/
action: 'send' | 'receive';
}
export type OutputEntry = PageOutput | OperationOutput | OutputGroup;
export interface PagesBuilderConfig {
toPages: (builder: PagesBuilder) => void;
}
export interface PagesBuilder {
id: string;
document: AsyncAPIObject;
dereferenceShallow: <T>(schema: T) => NoReferenceSwallow<T>;
create: (entry: OutputEntry) => void;
routePathToFilePath: (path: string) => string;
extract: () => ExtractedInfo;
fromExtractedOperation: (item: OperationItem) =>
| {
get displayName(): string;
channel: NoReference<ChannelObject>;
operation: NoReference<OperationObject>;
}
| undefined;
fromTag: (tag: TagObject) => {
get displayName(): string;
};
fromTagName: (tag: string) =>
| {
info: TagObject;
get displayName(): string;
}
| undefined;
}
interface ExtractedInfo {
operations: (OperationItem & {
tags?: string[];
channelAddress?: string;
})[];
}
export function fromSchema(
schemaId: string,
bundled: AsyncAPIObject,
config: PagesBuilderConfig,
): OutputEntry[] {
const files: OutputEntry[] = [];
const { toPages } = config;
toPages({
id: schemaId,
document: bundled,
dereferenceShallow: (s) => dereferenceShallow(s, bundled),
create(entry) {
files.push(entry);
},
extract() {
const result: ExtractedInfo = { operations: [] };
for (const [id, operationRef] of Object.entries(bundled.operations ?? {})) {
const operation = dereferenceShallow(operationRef, bundled);
if (!operation) continue;
const channel = dereferenceShallow(operation.channel, bundled);
result.operations.push({
id,
action: operation.action,
tags: getOperationTags(operation, bundled),
channelAddress: channel?.address ?? undefined,
});
}
return result;
},
routePathToFilePath(path) {
return path
.toLowerCase()
.replaceAll('.', '-')
.split('/')
.flatMap((v) => {
if (v.startsWith('{') && v.endsWith('}')) return v.slice(1, -1);
if (v.length === 0) return [];
return v;
})
.join('/');
},
fromExtractedOperation(item) {
const resolved = resolveOperation(item.id, bundled);
if (!resolved) return;
return {
channel: resolved.channel,
operation: resolved.operation,
get displayName() {
return getOperationDisplayName(item.id, resolved.operation, resolved.channel);
},
};
},
fromTag(tag) {
return {
get displayName() {
return getTagDisplayName(tag);
},
};
},
fromTagName(name) {
const tag = collectDocumentTags(bundled).find((item) => item.name === name);
if (!tag) return;
return {
info: tag,
...this.fromTag(tag),
};
},
});
return files;
}
export interface GeneratedPageProps {
document: string;
showTitle?: boolean;
showDescription?: boolean;
operations?: OperationItem[];
}
export function getPageProps(entry: PageOutput | OperationOutput): GeneratedPageProps {
if (entry.type === 'operation')
return {
document: entry.schemaId,
operations: [entry.item],
showDescription: true,
};
return {
showTitle: true,
showDescription: true,
document: entry.schemaId,
operations: entry.operations,
};
}
@@ -0,0 +1,279 @@
import * as path from 'node:path';
import type {
OperationOutput,
OutputEntry,
OutputGroup,
PageOutput,
PagesBuilder,
PagesBuilderConfig,
} from '@/utils/pages/builder';
import type { DistributiveOmit } from '@/types';
import { dereferenceShallow } from '@fumadocs/api-docs/schema/dereference';
import { getOperationTags } from '@/utils/operation';
interface OperationConfig extends BaseConfig {
per?: 'operation';
groupBy?:
| 'tag'
| 'channel'
| 'none'
| ((entry: DistributiveOmit<OperationOutput, 'path'>) => string);
name?: NameFn<OperationOutput> | NameFnOptions;
}
interface TagConfig extends BaseConfig {
per: 'tag';
name?: NameFn<PageOutput> | NameFnOptions;
}
interface SchemaConfig extends BaseConfig {
per: 'file';
name?: NameFn<PageOutput> | NameFnOptions;
}
export type SchemaToPagesOptions =
| SchemaConfig
| TagConfig
| OperationConfig
| ({
per: 'custom';
} & PagesBuilderConfig);
type NameFn<Entry extends OperationOutput | PageOutput = OperationOutput | PageOutput> = (
this: PagesBuilder,
output: DistributiveOmit<Entry, 'path'>,
) => string;
interface NameFnOptions {
algorithm?: 'v2' | 'v1';
}
interface BaseConfig {
slugify?: (name: string) => string;
}
export function createAutoPreset(options: SchemaToPagesOptions): PagesBuilderConfig {
if (options.per === 'custom') return options;
const {
slugify = (s) => {
return s.replace(/\s+/g, '-').toLowerCase();
},
} = options;
let nameFn: NameFn;
if (typeof options.name === 'function') {
nameFn = options.name as NameFn;
} else {
const { algorithm = 'v2' } = options.name ?? {};
nameFn = function (result) {
if (result.type === 'page') {
if (result.tag) return slugify(result.tag.name);
const schemaId = result.schemaId;
return schemaId.startsWith('http://') || schemaId.startsWith('https://')
? 'index'
: path.basename(schemaId, path.extname(schemaId));
}
if (algorithm === 'v2') {
return result.item.id;
}
return path.join(this.routePathToFilePath(result.item.id), result.item.action);
};
}
function group(
builder: PagesBuilder,
entries: DistributiveOmit<OperationOutput, 'path'>[],
): OutputEntry[] {
const groups = new Map<string, OutputGroup>();
const rest: OutputEntry[] = [];
const doc = builder.document;
const { groupBy = 'none' } = options as OperationConfig;
for (const entry of entries) {
switch (groupBy) {
case 'channel': {
const operation = dereferenceShallow(doc.operations?.[entry.item.id], doc);
const channel = operation ? dereferenceShallow(operation.channel, doc) : undefined;
const groupName = slugify(channel?.address || entry.item.id);
let group = groups.get(groupName);
if (!group) {
group = {
type: 'group',
info: {
title: channel?.title || channel?.address || groupName,
description: channel?.description,
},
entries: [],
schemaId: builder.id,
path: groupName,
};
groups.set(groupName, group);
}
group.entries.push({
...entry,
path: path.join(groupName, `${entry.item.action}.mdx`),
});
break;
}
case 'tag': {
const operation = dereferenceShallow(doc.operations?.[entry.item.id], doc) as
| import('@/types').OperationObject
| undefined;
let tags = operation ? getOperationTags(operation, doc) : [];
if (!tags || tags.length === 0) {
console.warn(
'When `groupBy` is set to `tag`, make sure a `tags` is defined for every operation schema.',
);
tags = ['unknown'];
}
for (const tag of tags) {
const res = builder.fromTagName(tag);
if (!res) continue;
const groupName = slugify(tag);
let group = groups.get(groupName);
if (!group) {
group = {
type: 'group',
info: { title: res.displayName, description: res.info.description },
tag: res.info,
entries: [],
schemaId: builder.id,
path: groupName,
};
groups.set(groupName, group);
}
group.entries.push({
...entry,
path: path.join(groupName, `${nameFn.call(builder, entry)}.mdx`),
});
}
break;
}
default: {
const fileName = `${nameFn.call(builder, entry)}.mdx`;
if (typeof groupBy === 'function') {
const groupDisplayName = groupBy(entry);
const groupName = slugify(groupDisplayName);
let group = groups.get(groupName);
if (!group) {
group = {
type: 'group',
info: { title: groupDisplayName },
entries: [],
schemaId: builder.id,
path: groupName,
};
groups.set(groupName, group);
}
group.entries.push({
...entry,
path: path.join(groupName, fileName),
});
break;
}
rest.push({
...entry,
path: fileName,
});
}
}
}
rest.push(...groups.values());
return rest;
}
return {
toPages(builder) {
const doc = builder.document;
const items = builder.extract();
if (options.per === 'file') {
const entry: PageOutput = {
type: 'page',
schemaId: builder.id,
path: '',
info: {
title: doc.info.title ?? 'Unknown',
description: doc.info.description,
},
operations: items.operations,
};
entry.path = `${nameFn.call(builder, entry)}.mdx`;
builder.create(entry);
return;
}
if (options.per === 'tag') {
const tags = builder.document.info.tags
? builder.document.info.tags
.map((tag) => dereferenceShallow(tag, doc))
.filter((tag): tag is NonNullable<typeof tag> => Boolean(tag))
: [];
const uniqueTags = new Map<string, (typeof tags)[number]>();
for (const tag of tags) uniqueTags.set(tag.name, tag);
for (const op of items.operations) {
for (const tagName of op.tags ?? []) {
const res = builder.fromTagName(tagName);
if (res) uniqueTags.set(tagName, res.info);
}
}
for (const tag of uniqueTags.values()) {
const { displayName } = builder.fromTag(tag);
const entry: PageOutput = {
type: 'page',
path: '',
schemaId: builder.id,
info: {
title: displayName,
description: tag.description,
},
operations: items.operations.filter((op) => op.tags?.includes(tag.name)),
tag,
};
entry.path = `${nameFn.call(builder, entry)}.mdx`;
builder.create(entry);
}
return;
}
const entries: DistributiveOmit<OperationOutput, 'path'>[] = [];
for (const op of items.operations) {
const { channel, operation, displayName } = builder.fromExtractedOperation(op)!;
entries.push({
type: 'operation',
schemaId: builder.id,
item: op,
info: {
title: displayName,
description: operation.description ?? channel.description,
},
});
}
for (const entry of group(builder, entries)) {
builder.create(entry);
}
},
};
}
@@ -0,0 +1,47 @@
import type { AsyncAPIObject } from '@/types';
import Slugger from 'github-slugger';
import type { TOCItemType } from 'fumadocs-core/toc';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
import type { GeneratedPageProps } from './builder';
import { resolveOperation } from '@/utils/operation';
export function toStaticData(
page: GeneratedPageProps,
doc: AsyncAPIObject,
): {
toc: TOCItemType[];
structuredData: StructuredData;
} {
const slugger = new Slugger();
const toc: TOCItemType[] = [];
const structuredData: StructuredData = { headings: [], contents: [] };
for (const item of page.operations ?? []) {
const resolved = resolveOperation(item.id, doc);
if (!resolved) continue;
if (page.showTitle) {
const title = resolved.operation.title || resolved.operation.summary || item.id;
const id = slugger.slug(title);
toc.push({
depth: 2,
title,
url: `#${id}`,
});
structuredData.headings.push({
content: title,
id,
});
}
if (resolved.operation.description) {
structuredData.contents.push({
content: resolved.operation.description,
heading: structuredData.headings.at(-1)?.id,
});
}
}
return { toc, structuredData };
}
@@ -0,0 +1,151 @@
import type { AsyncAPIObject, TagObject } from '@/types';
import { dump } from 'js-yaml';
import {
type GeneratedPageProps,
getPageProps,
type OperationItem,
type OperationOutput,
type PageOutput,
} from '@/utils/pages/builder';
import type { InternalAsyncAPIMeta } from '@/server';
import { toStaticData } from '@/utils/pages/to-static-data';
import { doubleQuote } from '@/utils/string-utils';
import { removeUndefined } from '../remove-undefined';
export interface PagesToTextOptions {
imports?: {
names: string[];
from: string;
}[];
frontmatter?: (
title: string,
description: string | undefined,
context: DocumentContext,
) => Record<string, unknown>;
includeDescription?: boolean;
addGeneratedComment?: boolean | string;
}
export function toText(
entry: PageOutput | OperationOutput,
doc: AsyncAPIObject,
options: PagesToTextOptions = {},
) {
const { frontmatter, includeDescription = false } = options;
const extend = frontmatter?.(
entry.info.title,
entry.info.description,
entry.type === 'page'
? entry.tag
? { type: 'tag', tag: entry.tag }
: { type: 'file' }
: { type: 'operation' },
);
const pageProps = getPageProps(entry);
if (!includeDescription) {
pageProps.showDescription = false;
}
let meta: InternalAsyncAPIMeta = {
preload: [entry.schemaId],
};
if (entry.type === 'operation') {
meta.action = entry.item.action;
meta.deprecated = entry.info.deprecated;
}
const data = toStaticData(pageProps, doc);
return generateDocument(
{
title: entry.info.title,
description: !includeDescription ? entry.info.description : undefined,
full: true,
...extend,
_asyncapi: {
...meta,
...data,
...(extend?._asyncapi as object | undefined),
},
},
pageContent(pageProps),
options,
);
}
export function generateDocument(
frontmatter: unknown,
content: string,
options: PagesToTextOptions,
): string {
const { addGeneratedComment = true, imports } = options;
const out: string[] = [];
const banner = dump(removeUndefined(frontmatter as object)).trimEnd();
if (banner.length > 0) out.push(`---\n${banner}\n---`);
if (addGeneratedComment) {
let commentContent =
'This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again.';
if (typeof addGeneratedComment === 'string') {
commentContent = addGeneratedComment;
}
commentContent = commentContent.replaceAll('/', '\\/');
out.push(`{/* ${commentContent} */}`);
}
if (imports) {
out.push(
...imports
.map((item) => `import { ${item.names.join(', ')} } from ${doubleQuote(item.from)};`)
.join('\n'),
);
}
out.push(content);
return out.join('\n\n');
}
export type DocumentContext =
| {
type: 'tag';
tag: TagObject;
}
| {
type: 'operation';
}
| {
type: 'file';
};
function pageContent({
showTitle,
showDescription,
document,
operations,
}: GeneratedPageProps): string {
const propStrs: string[] = [`document={${doubleQuote(document)}}`];
if (operations) {
propStrs.push(
`operations={${JSON.stringify(
operations.map(
(item) =>
({
id: item.id,
action: item.action,
}) satisfies OperationItem,
),
)}}`,
);
}
if (showTitle) {
propStrs.push(`showTitle`);
}
if (showDescription) {
propStrs.push(`showDescription`);
}
return `<APIPage ${propStrs.join(' ')} />`;
}
@@ -0,0 +1,36 @@
export function removeUndefined<T extends object>(value: T, deep = false): T {
const obj = value as Record<string, unknown>;
for (const key in obj) {
if (obj[key] === undefined) delete obj[key];
if (!deep) continue;
const entry = obj[key];
if (isPlainObject(entry)) {
removeUndefined(entry, deep);
continue;
}
if (Array.isArray(entry)) {
for (const item of entry) {
if (isPlainObject(item)) removeUndefined(item, deep);
}
}
}
return value;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null) {
return false;
}
const prototype = Object.getPrototypeOf(value);
return (
prototype === null ||
prototype === Object.prototype ||
Object.getPrototypeOf(prototype) === null
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { MessageExampleObject, TagObject } from '@/types';
import { idToTitle } from '@fumadocs/api-docs/utils/id-to-title';
export type { ParsedSchema } from '@fumadocs/api-docs/schema';
export function getTagDisplayName(tag: TagObject): string {
if ('x-displayName' in tag && typeof tag['x-displayName'] === 'string')
return tag['x-displayName'];
return idToTitle(tag.name);
}
export function pickMessageExample(example: MessageExampleObject): {
headers?: unknown;
payload?: unknown;
} {
return {
headers: example.headers,
payload: example.payload,
};
}
export function pickExample(value: {
example?: unknown;
examples?: MessageExampleObject[];
default?: unknown;
}): unknown | undefined {
if (value.example !== undefined) return value.example;
if (value.default !== undefined) return value.default;
if (value.examples && value.examples.length > 0) {
const first = value.examples[0];
return first.payload ?? first.headers;
}
}
@@ -0,0 +1,6 @@
/**
* Returns the input string wrapped in double quotes, escaping internal double quotes and backslashes.
*/
export function doubleQuote(str: string): string {
return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Generate AsyncAPI documents > Streetlights (Per File) 1`] = `
"## streetlights.mdx
---
title: Streetlights Kafka API
description: The Smartylighting Streetlights API allows you to manage streetlights.
full: true
_asyncapi:
preload:
- streetlights
toc:
- depth: 2
title: Receive light measurement
url: '#receive-light-measurement'
- depth: 2
title: Send light measurement
url: '#send-light-measurement'
structuredData:
headings:
- content: Receive light measurement
id: receive-light-measurement
- content: Send light measurement
id: send-light-measurement
contents: []
---
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
<APIPage document={"streetlights"} operations={[{"id":"receiveLightMeasurement","action":"receive"},{"id":"sendLightMeasurement","action":"send"}]} showTitle />"
`;
+59
View File
@@ -0,0 +1,59 @@
asyncapi: 3.0.0
info:
title: Streetlights Kafka API
version: 1.0.0
description: The Smartylighting Streetlights API allows you to manage streetlights.
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0
tags:
- name: lighting
description: Operations related to streetlights
defaultContentType: application/json
servers:
production:
host: test.mykafkacluster.org:8092
protocol: kafka
description: Production Kafka broker
channels:
lightMeasured:
address: light/measured
messages:
lightMeasuredMessage:
name: lightMeasured
title: Light measured
summary: Inform about environmental lighting conditions
payload:
type: object
properties:
lumens:
type: integer
description: Light intensity in lumens
sentAt:
type: string
format: date-time
description: Date and time when the event was sent
examples:
- name: default
payload:
lumens: 5
sentAt: '2020-08-31T13:00:00.000Z'
operations:
receiveLightMeasurement:
action: receive
channel:
$ref: '#/channels/lightMeasured'
summary: Receive light measurement
tags:
- name: lighting
messages:
- $ref: '#/channels/lightMeasured/messages/lightMeasuredMessage'
sendLightMeasurement:
action: send
channel:
$ref: '#/channels/lightMeasured'
summary: Send light measurement
tags:
- name: lighting
messages:
- $ref: '#/channels/lightMeasured/messages/lightMeasuredMessage'
+73
View File
@@ -0,0 +1,73 @@
import { fileURLToPath } from 'node:url';
import { describe, expect, test } from 'vitest';
import { generateFilesOnly } from '@/generate-file';
import { createAsyncAPI } from '@/server';
import path from 'node:path';
const cwd = fileURLToPath(new URL('./', import.meta.url));
function stringifyOutput(files: { path: string; content: string }[]) {
return files
.sort((a, b) => a.path.localeCompare(b.path))
.map((file) => `## ${file.path}\n\n${file.content}`)
.join('\n\n');
}
describe('Generate AsyncAPI documents', () => {
test('Streetlights (Per Operation)', async () => {
const out = await generateFilesOnly({
input: createAsyncAPI({
input: {
streetlights: path.join(cwd, './fixtures/streetlights.yaml'),
},
}),
per: 'operation',
});
expect(out).toHaveLength(2);
expect(out.some((file) => file.path.includes('receiveLightMeasurement'))).toBe(true);
expect(out.some((file) => file.path.includes('sendLightMeasurement'))).toBe(true);
expect(out.every((file) => file.content.includes('<APIPage'))).toBe(true);
expect(out.every((file) => file.content.includes('_asyncapi:'))).toBe(true);
});
test('Streetlights (Per Tag)', async () => {
const out = await generateFilesOnly({
input: createAsyncAPI({
input: {
streetlights: path.join(cwd, './fixtures/streetlights.yaml'),
},
}),
per: 'tag',
});
expect(out).toHaveLength(1);
expect(out[0].path).toContain('lighting');
expect(out[0].content).toContain('receiveLightMeasurement');
expect(out[0].content).toContain('sendLightMeasurement');
});
test('Streetlights (Per File)', async () => {
const out = await generateFilesOnly({
input: createAsyncAPI({
input: {
streetlights: path.join(cwd, './fixtures/streetlights.yaml'),
},
}),
per: 'file',
});
expect(out).toHaveLength(1);
expect(out[0].path).toContain('streetlights');
expect(stringifyOutput(out)).toMatchSnapshot();
});
test('throws error when no input files found', async () => {
await expect(
generateFilesOnly({
input: createAsyncAPI({ input: {} }),
per: 'file',
}),
).rejects.toThrowError();
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "tsconfig/react-library.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
},
"types": ["react/experimental"]
},
"exclude": ["node_modules", "dist", "eslint.config.mjs", "tailwind.config.js"]
}
+62
View File
@@ -0,0 +1,62 @@
import { defineConfig } from 'tsdown';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { Scanner } from '@tailwindcss/oxide';
export default defineConfig({
format: 'esm',
target: 'es2023',
entry: ['./src/{index,i18n}.ts', './src/ui/index.tsx', './src/server/index.tsx'],
unbundle: true,
dts: {
sourcemap: false,
},
sourcemap: false,
async onSuccess() {
await compileInline();
},
platform: 'browser',
deps: {
onlyBundle: [
'@fastify/deepmerge',
'@apidevtools/json-schema-ref-parser',
'ajv',
'fast-deep-equal',
'json-schema-traverse',
'fast-uri',
],
neverBundle: [/^node:/, 'fs', /ajv(.+)\.d\.ts/],
},
exports: {
enabled: true,
customExports(v) {
v['./css/*'] = './css/*';
return v;
},
},
});
async function compileInline() {
await mkdir('css/generated', { recursive: true });
const scanner = new Scanner({
sources: [
{
base: path.resolve('src'),
pattern: 'ui/**/*.{ts,tsx}',
negated: false,
},
{
base: path.resolve('src'),
pattern: 'server/**/*.tsx',
negated: false,
},
],
});
await writeFile('css/generated/shared.css', namesToFile(scanner.scan()));
console.log('generated CSS files');
}
function namesToFile(names: string[]) {
return `@source inline(${JSON.stringify(names.join(' '))});`;
}
+9
View File
@@ -0,0 +1,9 @@
import { defineProject } from 'vitest/config';
export default defineProject({
resolve: {
alias: {
'@/': new URL('./src/', import.meta.url).pathname,
},
},
});
+52 -12
View File
@@ -335,6 +335,58 @@ importers:
specifier: ^4.3.0
version: 4.3.0
examples/asyncapi:
dependencies:
'@fumadocs/asyncapi':
specifier: workspace:*
version: link:../../packages/asyncapi
fumadocs-core:
specifier: workspace:*
version: link:../../packages/core
fumadocs-mdx:
specifier: workspace:*
version: link:../../packages/mdx
fumadocs-ui:
specifier: workspace:*
version: link:../../packages/radix-ui
next:
specifier: 16.2.7
version: 16.2.7(@babel/[email protected])(@opentelemetry/[email protected])([email protected]([email protected]))([email protected])
react:
specifier: ^19.2.7
version: 19.2.7
react-dom:
specifier: ^19.2.7
version: 19.2.7([email protected])
shiki:
specifier: ^4.2.0
version: 4.2.0
devDependencies:
'@tailwindcss/postcss':
specifier: ^4.3.0
version: 4.3.0
'@types/mdx':
specifier: ^2.0.13
version: 2.0.13
'@types/react':
specifier: ^19.2.16
version: 19.2.16
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/[email protected])
postcss:
specifier: ^8.5.15
version: 8.5.15
rimraf:
specifier: ^6.1.3
version: 6.1.3
tailwindcss:
specifier: ^4.3.0
version: 4.3.0
typescript:
specifier: ^6.0.3
version: 6.0.3
examples/base-ui:
dependencies:
'@fumadocs/base-ui':
@@ -1949,12 +2001,6 @@ importers:
specifier: ^3.6.0
version: 3.6.0
devDependencies:
'@scalar/api-client-react':
specifier: ^2.0.21
version: 2.0.21([email protected])([email protected])([email protected])([email protected])
'@scalar/openapi-upgrader':
specifier: ^0.2.9
version: 0.2.9
'@tailwindcss/oxide':
specifier: ^4.3.0
version: 4.3.0
@@ -1967,9 +2013,6 @@ importers:
'@types/react':
specifier: ^19.2.16
version: 19.2.16
fast-content-type-parse:
specifier: ^3.0.0
version: 3.0.0
fumadocs-core:
specifier: workspace:*
version: link:../core
@@ -1988,9 +2031,6 @@ importers:
tsdown:
specifier: 0.22.1
version: 0.22.1(@tsdown/[email protected])([email protected](@emnapi/[email protected])(@emnapi/[email protected]))([email protected])([email protected])([email protected])
xml-js:
specifier: ^1.6.11
version: 1.6.11
packages/base-ui:
dependencies: