mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 10:55:57 +00:00
feat(asyncapi): implement
This commit is contained in:
@@ -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 ++]
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
content/docs/(api)/**/*.mdx
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pages": ["---API Documentation---", "index", "...", "---References---", "...(asyncapi)"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: 'AsyncAPI Playground',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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()],
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
export default withMDX({
|
||||
reactStrictMode: true,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
import { defineDocs } from 'fumadocs-mdx/config';
|
||||
|
||||
export const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import './generated/shared.css';
|
||||
@import '@fumadocs/api-docs/css/preset.css';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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 />"
|
||||
`;
|
||||
@@ -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'
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(' '))});`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineProject } from 'vitest/config';
|
||||
|
||||
export default defineProject({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': new URL('./src/', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+52
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user