Fumadocs Module (#831)

* init changes

* Core: Create search api from source

* Core: Support creating metadata API from sources

* UI: Refactor imports for layouts

* Fix a few mistakes

* Rename APIs

* CLI: Support plugins

* UI: Change default URL of title on i18n mode

* CLI: Support auto-updating configs for i18n

* CLI: Support i18n + og image plugins

* UI: Bundle icons into dist

* Docs: Fix build issues

* CLI: Add OpenAPI Plugin

* CLI: Improve instructions

* CLI: Support `add` command to add components

* UI: Avoid multiple referenced files

* CLI: Support custom import paths & config

* Docs: Update docs

* Add dev deps

* CLI: Add select for components
This commit is contained in:
Fuma Nama
2024-09-12 18:24:46 +08:00
committed by GitHub
parent d3630fb2b9
commit d9e908e1cd
165 changed files with 2044 additions and 768 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Bundle icons into dist
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-core': major
---
**Remove deprecated `languages` and `defaultLanguage` option from loader**
**migrate:** Use I18n config API
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Create search api from source (Support i18n without modifying search route handler)
+25
View File
@@ -0,0 +1,25 @@
---
'fumadocs-ui': major
---
**Refactor import paths for layouts**
**migrate:** Use
```ts
import { DocsLayout } from "fumadocs-ui/layouts/docs"
import { HomeLayout } from "fumadocs-ui/layouts/home"
import { BaseLayoutProps } from "fumadocs-ui/layouts/shared"
```
Instead of
```ts
import { DocsLayout } from "fumadocs-ui/layout"
import { HomeLayout } from "fumadocs-ui/home-layout"
import { HomeLayoutProps } from "fumadocs-ui/home-layout"
```
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Change default URL of title on i18n mode
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Add center to root toggle
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': major
---
Improved usage for `createI18nSearchAPI` (replaced `createI18nSearchAPIExperimental`)
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-ui': major
---
**Remove `getImageMeta` from `fumadocs-ui/og`**
**migrate:** Use Metadata API from `fumadocs-core/server`
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': major
'fumadocs-ui': major
---
Replace `fumadocs-core/search/shared` with `fumadocs-core/server`
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support creating metadata API from sources
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/home-layout';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/app/layout.config';
export default function Layout({
+10 -15
View File
@@ -14,9 +14,8 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
import { Callout } from 'fumadocs-ui/components/callout';
import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import { getImageMeta } from 'fumadocs-ui/og';
import Preview from '@/components/preview';
import { createMetadata } from '@/utils/metadata';
import { createMetadata, metadataImage } from '@/utils/metadata';
import { openapi, utils } from '@/app/source';
import { Wrapper } from '@/components/preview/wrapper';
@@ -91,19 +90,15 @@ export function generateMetadata({ params }: { params: Param }): Metadata {
const description =
page.data.description ?? 'The library for building documentation sites';
const image = getImageMeta('og', page.slugs);
return createMetadata({
title: page.data.title,
description,
openGraph: {
url: `/docs/${page.slugs.join('/')}`,
images: image,
},
twitter: {
images: image,
},
});
return createMetadata(
metadataImage.withImage(page.slugs, {
title: page.data.title,
description,
openGraph: {
url: `/docs/${page.slugs.join('/')}`,
},
}),
);
}
export function generateStaticParams(): Param[] {
+1 -1
View File
@@ -1,4 +1,4 @@
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { docsOptions } from '@/app/layout.config';
import 'fumadocs-ui/twoslash.css';
+3 -3
View File
@@ -1,14 +1,14 @@
import { type DocsLayoutProps } from 'fumadocs-ui/layout';
import { type DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
import { BookIcon, Heart, LayoutTemplateIcon } from 'lucide-react';
import Image from 'next/image';
import { RootToggle } from 'fumadocs-ui/components/layout/root-toggle';
import type { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { FumadocsIcon, NavChildren } from '@/app/layout.client';
import Logo from '@/public/logo.png';
import { utils } from '@/app/source';
import { modes } from '@/utils/modes';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
githubUrl: 'https://github.com/fuma-nama/fumadocs',
nav: {
title: (
+5 -22
View File
@@ -1,29 +1,15 @@
import { readFileSync } from 'node:fs';
import { generateOGImage } from 'fumadocs-ui/og';
import { type ImageResponse } from 'next/og';
import { type NextRequest } from 'next/server';
import { notFound } from 'next/navigation';
import { utils } from '@/app/source';
import { metadataImage } from '@/utils/metadata';
const font = readFileSync('./app/og/[...slug]/Geist-Regular.ttf');
const fontBold = readFileSync('./app/og/[...slug]/Geist-Bold.ttf');
export function GET(
_: NextRequest,
{
params,
}: {
params: {
slug: string[];
};
},
): ImageResponse {
const page = utils.getPage(params.slug.slice(0, -1));
if (!page) notFound();
export const GET = metadataImage.createAPI((page): ImageResponse => {
return generateOGImage({
primaryTextColor: 'rgb(240,240,240)',
primaryColor: 'rgba(255,255,255,0.3)',
primaryColor: 'rgba(255,255,255,0.2)',
title: page.data.title,
icon: (
<svg
@@ -63,13 +49,10 @@ export function GET(
},
],
});
}
});
export function generateStaticParams(): {
slug: string[];
}[] {
return utils.generateParams().map((param) => ({
...param,
slug: [...param.slug, 'og.png'],
}));
return metadataImage.generateParams();
}
+1 -1
View File
@@ -58,7 +58,7 @@ export const utils = loader({
The language switch is now added to Docs Layout.
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
export default function Layout({ children }: { children: React.ReactNode }) {
return <DocsLayout i18n>{children}</DocsLayout>;
@@ -56,9 +56,9 @@ And pass it to Source API.
```json doc-gen:file
{
"file": "../../examples/content-collections/app/source.ts",
"file": "../../examples/content-collections/lib/source.ts",
"codeblock": {
"meta": "title=\"app/source.ts\""
"meta": "title=\"lib/source.ts\""
}
}
```
@@ -66,7 +66,7 @@ And pass it to Source API.
Done! You can access the pages and generated page tree from Source API.
```ts
import { getPage } from '@/app/source';
import { getPage } from '@/lib/source';
const page = getPage(slugs);
@@ -12,7 +12,7 @@ Define the i18n configurations in a file, we will import it with `@/i18n` in thi
```json doc-gen:file
{
"file": "../../examples/i18n/i18n.ts",
"file": "../../examples/i18n/lib/i18n.ts",
"codeblock": {
"lang": "ts",
"meta": "title=\"i18n.ts\""
@@ -35,7 +35,7 @@ export const source = loader({
Update the usages to your source:
```ts
import { source } from '@/app/source';
import { source } from '@/lib/source';
// get page tree
source.pageTree[params.lang];
@@ -121,7 +121,7 @@ Make sure to update references to `source` and configure Static Site Generation
```json doc-gen:file
{
"file": "../../examples/i18n/app/[lang]/[[...slug]]/page.tsx",
"file": "../../examples/i18n/app/[lang]/docs/[[...slug]]/page.tsx",
"codeblock": {
"lang": "tsx",
"meta": "title=\"page.tsx\""
@@ -183,9 +183,9 @@ import { I18nProvider } from 'fumadocs-ui/i18n';
To allow users changing their language, enable `i18n` on your layouts.
```tsx title="app/layout.config.tsx" twoslash
import type { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
i18n: true,
};
```
@@ -79,7 +79,7 @@ It's useful for implementing versioned docs, or multi-docs similar to this
documentation.
```ts
import { source } from '@/app/source';
import { source } from '@/lib/source';
import { createSearchAPI } from 'fumadocs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
@@ -12,7 +12,7 @@ It requires a page tree and the url of page.
```ts
import { findNeighbour } from 'fumadocs-core/server';
import { pageTree } from '@/app/source';
import { pageTree } from '@/lib/source';
const neighbours = findNeighbour(pageTree, '/url/to/page');
```
+1 -1
View File
@@ -69,7 +69,7 @@ You can add a post install script to generate types before executing CLI tools (
To integrate with Fumadocs, create:
```ts title="app/source.ts"
```ts title="lib/source.ts"
import { docs, meta } from '@/.source';
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
+1 -1
View File
@@ -21,7 +21,7 @@ export default defineConfig({
After doing this, a `lastModified` number will be exported for each document, you can convert it to a JavaScript Date object.
```js
import { source } from '@/app/source';
import { source } from '@/lib/source';
const page = source.getPage('...');
@@ -12,26 +12,35 @@ Make sure to read their [Metadata section](https://nextjs.org/docs/app/building-
### Open Graph Image
For docs pages, Fumadocs UI has a built-in dynamic image generator.
For docs pages, Fumadocs has a built-in dynamic image generator.
```json doc-gen:file
{
"file": "../../examples/next-mdx/lib/metadata.ts",
"codeblock": {
"meta": "title=\"lib/metadata.ts\""
}
}
```
You will need a route handler to get started.
```json doc-gen:file
{
"file": "../../examples/next-mdx-og/app/og/[...slug]/route.tsx",
"file": "../../examples/next-mdx/app/docs-og/[...slug]/route.tsx",
"codeblock": {
"meta": "title=\"app/og/[...slug]/route.tsx\""
"meta": "title=\"app/docs-og/[...slug]/route.tsx\""
}
}
```
> We need to append `og.png` to the end of slugs so that we can access it via `/og/my-page/og.png`.
> We need to append `og.png` to the end of slugs so that we can access it via `/docs-og/my-page/og.png`.
In your docs page, add the image to metadata.
```json doc-gen:file
{
"file": "../../examples/next-mdx-og/app/docs/[[...slug]]/page.tsx",
"file": "../../examples/next-mdx/app/docs/[[...slug]]/page.tsx",
"codeblock": {
"meta": "title=\"app/docs/[[...slug]]/page.tsx\""
}
@@ -47,7 +47,7 @@ Add `APIPage` to your MDX Components, so that you can use it in MDX files.
```tsx title="page.tsx"
import defaultComponents from 'fumadocs-ui/mdx';
import { openapi } from '@/app/source';
import { openapi } from '@/lib/source';
<MDX
components={{
@@ -12,7 +12,7 @@ Add a [navbar](/docs/ui/blocks/navbar) and search dialog across other pages.
It doesn't contain a sidebar, a page tree is not required.
```jsx
import { HomeLayout } from 'fumadocs-ui/home-layout';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
export default function Layout({ children }) {
return <HomeLayout>{children}</HomeLayout>;
+3 -3
View File
@@ -12,7 +12,7 @@ The layout of documentation pages, it includes a sidebar and [navbar](/docs/ui/b
Pass your page tree to the component.
```jsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
export default function Layout({ children }) {
return <DocsLayout tree={tree}>{children}</DocsLayout>;
@@ -24,7 +24,7 @@ export default function Layout({ children }) {
Provide elements to navigate between pages.
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
<DocsLayout sidebar={{ footer: <ShareButton /> }} />;
```
@@ -41,7 +41,7 @@ It can also hit the limits of some other hosting platforms.
You can disable prefetching to reduce the amount of RSC requests.
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
<DocsLayout sidebar={{ prefetch: false }} />;
```
+16 -16
View File
@@ -14,9 +14,9 @@ A link to navigate to a URL/href, can be external.
```tsx title="app/layout.config.tsx"
import { BookIcon } from 'lucide-react';
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
icon: <BookIcon />,
@@ -38,9 +38,9 @@ The conditions to be marked as active.
| `none` | Never be active |
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
text: 'Blog',
@@ -56,9 +56,9 @@ export const baseOptions: HomeLayoutProps = {
Set the item as secondary, secondary items will be displayed differently on navbar.
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
text: 'Blog',
@@ -74,9 +74,9 @@ export const baseOptions: HomeLayoutProps = {
You can restrict where the item is displayed.
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
on: 'menu',
@@ -94,9 +94,9 @@ Icon items are secondary by default.
```tsx title="app/layout.config.tsx"
import { BookIcon } from 'lucide-react';
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
type: 'icon',
@@ -114,9 +114,9 @@ export const baseOptions: HomeLayoutProps = {
Same as link item, but is shown as a button.
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
type: 'button',
@@ -132,9 +132,9 @@ export const baseOptions: HomeLayoutProps = {
A dropdown menu containing multiple items.
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
type: 'menu',
@@ -152,9 +152,9 @@ export const baseOptions: HomeLayoutProps = {
Display a custom component.
```tsx title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
type: 'custom',
+4 -4
View File
@@ -8,9 +8,9 @@ description: Top navigation bar
The navigation header provides common interactive elements.
```tsx
import type { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
githubUrl: 'https://github.com/fuma-nama/fumadocs',
nav: {
title: 'My App',
@@ -28,9 +28,9 @@ To make the navbar background transparent, you can configure transparent mode.
It uses the `none` mode by default.
```tsx
import type { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
nav: {
transparentMode: 'top',
},
+1 -1
View File
@@ -57,7 +57,7 @@ Optional, display the other pages in its folder.
```tsx
import { DocsPage, DocsCategory } from 'fumadocs-ui/page';
import { getPages } from '@/app/source';
import { getPages } from '@/lib/source';
<DocsPage>
<DocsCategory page={page} pages={getPages()} />
@@ -9,7 +9,7 @@ preview: root-toggle
Add this component to your sidebar or other places you want.
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { RootToggle } from 'fumadocs-ui/components/layout/root-toggle';
<DocsLayout
+1 -1
View File
@@ -120,7 +120,7 @@ npm run dev
In the project, you can see:
- `app/source.ts`: Where you organize code for the source adapter, it is used here to read from your content source.
- `lib/source.ts`: Where you organize code for the source adapter, it is used here to read from your content source.
- `app/layout.config.tsx`: Shared options for layouts, this is optional but preferred to keep.
- `app/(home)`: The route group for your landing page and other pages.
- `app/docs`: The entire docs.
+8 -8
View File
@@ -22,7 +22,7 @@ You can see [Organizing Pages](/docs/ui/page-conventions) to learn how to custom
It is passed to the [Docs Layout](/docs/ui/blocks/layout) component and shared to navigation elements:
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
<DocsLayout
tree={
@@ -39,9 +39,9 @@ They are displayed on all Fumadocs UI layouts (e.g. Home Layout and Docs Layout)
Links items are passed to the layout.
```tsx twoslash title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
links: [
{
text: 'Documentation',
@@ -57,9 +57,9 @@ export const baseOptions: HomeLayoutProps = {
A shortcut for adding GitHub repository link item.
```tsx twoslash title="app/layout.config.tsx"
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
githubUrl: 'https://github.com',
};
```
@@ -71,9 +71,9 @@ A part of all Fumadocs UI layouts, it displays the link items and your logo.
```tsx twoslash title="app/layout.config.tsx"
import React from 'react';
// ---cut---
import { type HomeLayoutProps } from 'fumadocs-ui/home-layout';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
nav: {
// Give it a logo
title: <div>Logo</div>,
@@ -95,7 +95,7 @@ You can use them to add additional components.
For example, adding an announcement to the top of sidebar.
```tsx
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
<DocsLayout
sidebar={{
+1 -1
View File
@@ -4,7 +4,7 @@ import type { File, Folder } from 'fumadocs-ui/components/files';
import type { InlineTOC } from 'fumadocs-ui/components/inline-toc';
import type { TypeTable } from 'fumadocs-ui/components/type-table';
import type { Card } from 'fumadocs-ui/components/card';
import type { DocsLayoutProps } from 'fumadocs-ui/layout';
import type { DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
import type { AnchorHTMLAttributes, ComponentPropsWithoutRef } from 'react';
import type { DocsPageProps } from 'fumadocs-ui/page';
import type { AutoTypeTable } from 'fumadocs-typescript/ui';
+7
View File
@@ -1,4 +1,6 @@
import type { Metadata } from 'next/types';
import { createMetadataImage } from 'fumadocs-core/server';
import { utils } from '@/app/source';
export function createMetadata(override: Metadata): Metadata {
return {
@@ -26,3 +28,8 @@ export const baseUrl =
process.env.NODE_ENV === 'development'
? new URL('http://localhost:3000')
: new URL(`https://${process.env.VERCEL_URL!}`);
export const metadataImage = createMetadataImage({
source: utils,
imageRoute: 'og',
});
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/home-layout';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/app/layout.config';
export default function Layout({ children }: { children: ReactNode }) {
@@ -1,11 +1,4 @@
import { source } from '@/app/source';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
})),
});
export const { GET } = createFromSource(source);
@@ -1,4 +1,4 @@
import { source } from '@/app/source';
import { source } from '@/lib/source';
import type { Metadata } from 'next';
import {
DocsPage,
@@ -1,5 +1,5 @@
import { source } from '../source';
import { DocsLayout } from 'fumadocs-ui/layout';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
@@ -1,6 +1,6 @@
import { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
nav: {
title: 'My App',
},
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/app/layout.config';
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}
+38
View File
@@ -0,0 +1,38 @@
import Link from 'next/link';
export default function HomePage() {
return (
<main
style={{
height: '100vh',
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,9 @@
import { createAPI } from '@/lib/metadata';
import { generateOGImage } from 'fumadocs-ui/src/og';
export const GET = createAPI((page) => {
return generateOGImage({
title: page.data.title,
description: page.data.description,
});
});
@@ -1,5 +1,4 @@
import { source } from '@/app/source';
import type { Metadata } from 'next';
import { source } from '@/lib/source';
import {
DocsPage,
DocsBody,
@@ -8,6 +7,7 @@ import {
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { withImage } from '@/lib/metadata';
export default async function Page({
params,
@@ -42,8 +42,8 @@ export function generateMetadata({
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {
return withImage(page.slugs, {
title: page.data.title,
description: page.data.description,
} satisfies Metadata;
});
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { baseOptions } from '@/app/layout.config';
export default function Layout({
params,
children,
}: {
params: { lang: string };
children: ReactNode;
}) {
return (
<DocsLayout {...baseOptions} tree={source.pageTree[params.lang]}>
{children}
</DocsLayout>
);
}
+2 -15
View File
@@ -1,10 +1,8 @@
import { RootProvider } from 'fumadocs-ui/provider';
import 'fumadocs-ui/style.css';
import { RootProvider } from 'fumadocs-ui/provider';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';
import { I18nProvider } from 'fumadocs-ui/i18n';
import { source } from '@/app/source';
import { DocsLayout } from 'fumadocs-ui/layout';
const inter = Inter({
subsets: ['latin'],
@@ -46,18 +44,7 @@ export default function Layout({
}[lang]
}
>
<RootProvider>
<DocsLayout
tree={source.pageTree[lang]}
nav={{
title: lang === 'cn' ? '文檔' : 'My App',
url: `/${lang}`,
}}
i18n
>
{children}
</DocsLayout>
</RootProvider>
<RootProvider>{children}</RootProvider>
</I18nProvider>
</body>
</html>
+3 -15
View File
@@ -1,16 +1,4 @@
import { source } from '@/app/source';
import { i18n } from '@/i18n';
import { createI18nSearchAPIExperimental } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createI18nSearchAPIExperimental('advanced', {
i18n,
indexes: source.getLanguages().flatMap((entry) => {
return entry.pages.map((page) => ({
id: page.url,
url: page.url,
title: page.data.title,
structuredData: page.data.structuredData,
locale: entry.language,
}));
}),
});
export const { GET } = createFromSource(source);
+5
View File
@@ -0,0 +1,5 @@
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: BaseLayoutProps = {
i18n: true,
};
-10
View File
@@ -1,10 +0,0 @@
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { i18n } from '@/i18n';
import { docs, meta } from '@/.source';
export const source = loader({
baseUrl: '/',
source: createMDXSource(docs, meta),
i18n,
});
+6
View File
@@ -0,0 +1,6 @@
import { createMetadataImage } from 'fumadocs-core/server';
import { source } from '@/lib/source';
export const { createAPI, withImage, generateParams } = createMetadataImage({
source,
});
@@ -1,8 +1,10 @@
import { docs, meta } from '@/.source';
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { i18n } from '@/lib/i18n';
import { docs, meta } from '@/.source';
export const source = loader({
baseUrl: '/docs',
source: createMDXSource(docs, meta),
i18n,
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { createI18nMiddleware } from 'fumadocs-core/i18n';
import { i18n } from '@/i18n';
import { i18n } from '@/lib/i18n';
export default createI18nMiddleware(i18n);
@@ -1,11 +0,0 @@
import { source } from '@/app/source';
import { createSearchAPI } from 'fumadocs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
})),
});
@@ -1,55 +0,0 @@
import { source } from '@/app/source';
import type { Metadata } from 'next';
import {
DocsPage,
DocsBody,
DocsTitle,
DocsDescription,
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { getImageMeta } from 'fumadocs-ui/og';
export default async function Page({
params,
}: {
params: { slug?: string[] };
}) {
const page = source.getPage(params.slug);
if (!page) notFound();
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={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
const page = source.getPage(params.slug);
if (!page) notFound();
const image = getImageMeta('og', page.slugs);
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: image,
},
twitter: {
images: image,
card: 'summary_large_image',
},
} satisfies Metadata;
}
-12
View File
@@ -1,12 +0,0 @@
import { source } from '../source';
import { DocsLayout } from 'fumadocs-ui/layout';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}
@@ -1,7 +0,0 @@
import type { HomeLayoutProps } from 'fumadocs-ui/home-layout';
export const baseOptions: HomeLayoutProps = {
nav: {
title: 'My App',
},
};
-18
View File
@@ -1,18 +0,0 @@
import { RootProvider } from 'fumadocs-ui/provider';
import 'fumadocs-ui/style.css';
import { Inter } from 'next/font/google';
import type { ReactNode } from 'react';
const inter = Inter({
subsets: ['latin'],
});
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}
@@ -1,25 +0,0 @@
import { generateOGImage } from 'fumadocs-ui/og';
import { type NextRequest } from 'next/server';
import { notFound } from 'next/navigation';
import { source } from '@/app/source';
export function GET(
_: NextRequest,
{ params }: { params: { slug: string[] } },
) {
const page = source.getPage(params.slug.slice(0, -1));
if (!page) notFound();
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: 'My App',
});
}
export function generateStaticParams() {
return source.generateParams().map((params) => ({
...params,
slug: [...params.slug, 'og.png'],
}));
}
@@ -1,26 +0,0 @@
---
title: Hello World
description: |
Your first `document`
```ts
console.log("Hello World")
```
---
Hey there! fsd asfd sdfsfda
sfd
fdsa ff dsaf sdf sda fasd fsd
## Heading
Hello World dsasfdafsd
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Fumadocs" href="https://fumadocs.vercel.app" />
</Cards>
### Heading
#### Heading
@@ -1,21 +0,0 @@
---
title: Hello World
description: Your first document
---
Hey there!
## Heading
<Cards>
<Card title="Learn more about Next.js" href="https://nextjs.org/docs" />
<Card title="Learn more about Fumadocs" href="https://fumadocs.vercel.app" />
</Cards>
### Heading
```js
console.log('Hello World');
```
#### Heading
@@ -1,6 +0,0 @@
---
title: Fumadocs
description: It's night, I love it
---
Hey there! It's 12:13 AM now.
-10
View File
@@ -1,10 +0,0 @@
import { createMDX } from 'fumadocs-mdx/next';
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);
-24
View File
@@ -1,24 +0,0 @@
{
"name": "example-next-mdx-og",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev --turbo",
"start": "next start"
},
"dependencies": {
"fumadocs-core": "workspace:*",
"fumadocs-mdx": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "^14.2.8",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/mdx": "^2.0.13",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4"
}
}
-5
View File
@@ -1,5 +0,0 @@
import { defineDocs, defineConfig } from 'fumadocs-mdx/config';
export const { docs, meta } = defineDocs();
export default defineConfig();
-29
View File
@@ -1,29 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/home-layout';
import { baseOptions } from '../layout.config';
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { baseOptions } from '@/app/layout.config';
export default function Layout({
children,
+3 -11
View File
@@ -1,12 +1,4 @@
import { source } from '@/app/source';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
title: page.data.title,
description: page.data.description,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
})),
});
export const { GET } = createFromSource(source);
@@ -0,0 +1,14 @@
import { generateOGImage } from 'fumadocs-ui/og';
import { metadataImage } from '@/lib/metadata';
export const GET = metadataImage.createAPI((page) => {
return generateOGImage({
title: page.data.title,
description: page.data.description,
site: 'My App',
});
});
export function generateStaticParams() {
return metadataImage.generateParams();
}
@@ -1,5 +1,4 @@
import { source } from '@/app/source';
import type { Metadata } from 'next';
import { source } from '@/lib/source';
import {
DocsPage,
DocsBody,
@@ -8,6 +7,7 @@ import {
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { metadataImage } from '@/lib/metadata';
export default async function Page({
params,
@@ -36,10 +36,10 @@ export async function generateStaticParams() {
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
const page = source.getPage(params.slug);
if (!page) notFound();
return {
return metadataImage.withImage(page.slugs, {
title: page.data.title,
} satisfies Metadata;
description: page.data.description,
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { source } from '../source';
import { DocsLayout } from 'fumadocs-ui/layout';
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
+2 -2
View File
@@ -1,6 +1,6 @@
import { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
nav: {
title: 'My App',
},
+6
View File
@@ -0,0 +1,6 @@
import { createMetadataImage } from 'fumadocs-core/server';
import { source } from '@/lib/source';
export const metadataImage = createMetadataImage({
source,
});
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { HomeLayout } from 'fumadocs-ui/src/home-layout';
import { HomeLayout } from 'fumadocs-ui/home-layout';
import { baseOptions } from '@/app/layout.config';
export default function Layout({ children }: { children: ReactNode }) {
+3 -10
View File
@@ -1,11 +1,4 @@
import { source } from '@/app/source';
import { createSearchAPI } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createSearchAPI('advanced', {
indexes: source.getPages().map((page) => ({
title: page.data.title,
structuredData: page.data.structuredData,
id: page.url,
url: page.url,
})),
});
export const { GET } = createFromSource(source);
@@ -0,0 +1,13 @@
import { metadataImage } from '@/lib/metadata';
import { generateOGImage } from 'fumadocs-ui/og';
export const GET = metadataImage.createAPI((page) => {
return generateOGImage({
title: page.data.title,
description: page.data.description,
});
});
export function generateStaticParams() {
return metadataImage.generateParams();
}
@@ -1,5 +1,4 @@
import { source, openapi } from '@/app/source';
import type { Metadata } from 'next';
import { source, openapi } from '@/lib/source';
import {
DocsPage,
DocsBody,
@@ -8,6 +7,7 @@ import {
} from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { metadataImage } from '@/lib/metadata';
export default async function Page({
params,
@@ -47,8 +47,8 @@ export function generateMetadata({ params }: { params: { slug?: string[] } }) {
if (!page) notFound();
return {
return metadataImage.withImage(page.slugs, {
title: page.data.title,
description: page.data.description,
} satisfies Metadata;
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { source } from '../source';
import { DocsLayout } from 'fumadocs-ui/layout';
import { source } from '../../lib/source';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
+2 -2
View File
@@ -1,6 +1,6 @@
import { HomeLayoutProps } from 'fumadocs-ui/home-layout';
import { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
export const baseOptions: HomeLayoutProps = {
export const baseOptions: BaseLayoutProps = {
nav: {
title: <span className="text-lg font-bold py-2">Unkey Docs</span>,
},
+6
View File
@@ -0,0 +1,6 @@
import { createMetadataImage } from 'fumadocs-core/server';
import { source } from '@/lib/source';
export const metadataImage = createMetadataImage({
source,
});
@@ -1,6 +1,6 @@
import { createMDXSource } from 'fumadocs-mdx';
import { loader } from 'fumadocs-core/source';
import { attachFile, createOpenAPI } from 'fumadocs-openapi/server';
import { loader } from 'fumadocs-core/dist/source';
import { attachFile, createOpenAPI } from 'fumadocs-openapi/dist/server';
import { docs, meta } from '@/.source';
export const source = loader({
+7 -4
View File
@@ -1,15 +1,18 @@
import * as OpenAPI from 'fumadocs-openapi';
import { rimrafSync } from 'rimraf';
rimrafSync('./content/docs/(api)', {
const out = './content/docs/(api)';
// clean generated files
rimrafSync(out, {
filter(v) {
return !v.endsWith('index.mdx') && !v.endsWith('meta.json');
},
});
void OpenAPI.generateFiles({
input: ['./unkey.json'],
output: './content/docs/(api)',
per: 'operation',
// input files
input: ['./openapi.json'],
output: out,
groupBy: 'tag',
});
+1 -1
View File
@@ -1,5 +1,5 @@
import { getDocs } from '../source';
import { DocsLayout } from 'fumadocs-ui/layout';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { initHotReload } from '@fumadocs/mdx-remote/github/next';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['custom/library'],
};
+1
View File
@@ -0,0 +1 @@
src/generated.js
+48
View File
@@ -0,0 +1,48 @@
{
"name": "fumadocs-cli",
"version": "0.0.0",
"description": "The CLI tool for Fumadocs",
"keywords": [
"NextJs",
"Docs",
"Fumadocs"
],
"homepage": "https://fumadocs.vercel.app",
"repository": "github:fuma-nama/fumadocs",
"license": "MIT",
"author": "Fuma Nama",
"type": "module",
"bin": {
"fumadocs": "./dist/index.js"
},
"files": [
"dist/*"
],
"scripts": {
"build": "tsup",
"clean": "rimraf dist",
"dev": "tsup --watch",
"lint": "eslint .",
"sync": "tsx ./scripts/sync.ts",
"types:check": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"commander": "^12.1.0",
"cross-spawn": "^7.0.3",
"package-manager-detector": "^0.2.0",
"picocolors": "^1.1.0",
"ts-morph": "^23.0.0"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.6",
"@types/node": "22.5.4",
"eslint-config-custom": "workspace:*",
"fast-glob": "^3.3.1",
"tsconfig": "workspace:*",
"tsx": "^4.19.0"
},
"publishConfig": {
"access": "public"
}
}
+45
View File
@@ -0,0 +1,45 @@
import * as fs from 'node:fs/promises';
import path from 'node:path';
import fg from 'fast-glob';
export const templates = {
'lib/metadata': './examples/next-mdx/lib/metadata.ts',
'app/docs-og/[...slug]/route':
'./examples/next-mdx/app/docs-og/[...slug]/route.tsx',
'lib/i18n': './examples/i18n/lib/i18n.ts',
middleware: './examples/i18n/middleware.ts',
'scripts/generate-docs': './examples/openapi/scripts/generate-docs.mjs',
};
export async function sync(): Promise<void> {
const generated: Record<string, string> = {};
const resolve = Object.entries(templates).map(async ([name, file]) => {
generated[name] = await fs
.readFile(
// from root dir
path.resolve('../../', file),
)
.then((v) => v.toString());
});
await Promise.all(resolve);
const files = await fg(['*.tsx', '!api.tsx', '!*.client.tsx'], {
cwd: path.resolve('../../packages/ui/src/components'),
});
const components = files.map((file) =>
path.basename(file, path.extname(file)),
);
const out = `
export const generated = ${JSON.stringify(generated)}
export const components = ${JSON.stringify(components)}
`;
await fs.writeFile('./src/generated.js', out);
}
void sync();
+178
View File
@@ -0,0 +1,178 @@
import path from 'node:path';
import { sync as spawnSync } from 'cross-spawn';
import { log, confirm, isCancel, outro, spinner, intro } from '@clack/prompts';
import { type Project } from 'ts-morph';
import picocolors from 'picocolors';
import { createEmptyProject } from '@/utils/typescript';
import { getPackageManager } from '@/utils/get-package-manager';
import { exists } from '@/utils/fs';
import { type Config } from '@/config';
import {
getOutputPath,
toReferencePath,
transformReferences,
} from '@/utils/transform-references';
import { isSrc } from '@/utils/is-src';
interface Context {
config: Config;
project: Project;
branch: string;
src: boolean;
}
/**
* A set of downloaded paths (without ext)
*/
const downloadedFiles = new Map<string, FindResult>();
const excludedDeps = ['react', 'next', 'fumadocs-ui', 'fumadocs-core'];
export async function add(
name: string,
branch = 'main',
config: Config = {},
): Promise<void> {
intro(picocolors.bold(picocolors.bgCyan(picocolors.black('Add Component'))));
const project = createEmptyProject();
const result = await downloadFile(
`src/components/${name}`,
getOutputPath(`components/${name}`, config),
{
project,
branch,
config,
src: await isSrc(),
},
);
if (!result) {
log.error(`Component: ${name} not found`);
process.exit(0);
}
const deps = result.deps.filter((v) => !excludedDeps.includes(v));
const value = await confirm({
message: `This component requires extra dependencies (${deps.join(' ')}), do you want to install?`,
});
if (isCancel(value)) {
outro(picocolors.bold(picocolors.greenBright('Component downloaded')));
process.exit(0);
}
if (value) {
const spin = spinner();
spin.start('Installing dependencies...');
spawnSync(`${await getPackageManager()} install ${deps.join(' ')}`, {
stdio: 'ignore',
});
spin.stop('Dependencies installed.');
}
outro(picocolors.bold(picocolors.greenBright('Component installed')));
}
interface DownloadFileResult {
deps: string[];
found: FindResult;
}
async function downloadFile(
pathWithoutExt: string,
outPathWithoutExt: string,
ctx: Context,
): Promise<DownloadFileResult | undefined> {
const deps: string[] = [];
const rootUrl = `https://raw.githubusercontent.com/fuma-nama/fumadocs/${ctx.branch}/packages/ui`;
const found = await find(rootUrl, pathWithoutExt);
if (!found) return;
const outPath = `${outPathWithoutExt}.${found.ext}`;
const file = ctx.project.createSourceFile(outPath, found.code, {
overwrite: true,
});
await transformReferences(
file,
{
alias: 'src',
relativeTo: path.dirname(pathWithoutExt),
},
async (resolved) => {
if (resolved.type === 'dep') {
deps.push(resolved.name);
return;
}
const downloadPath = getOutputPath(
path.relative('src', resolved.path),
ctx.config,
);
const downloaded = await downloadFile(resolved.path, downloadPath, ctx);
if (!downloaded)
throw new Error(`Cannot download file: ${resolved.path}`);
deps.push(...downloaded.deps);
return toReferencePath(outPath, downloadPath);
},
);
const isOverride = await exists(outPath);
if (isOverride) {
const value = await confirm({
message: `Do you want to override ${outPath}?`,
});
if (isCancel(value)) {
outro('Ended');
process.exit(0);
}
if (value) {
await file.save();
}
} else {
await file.save();
}
return {
found,
deps,
};
}
interface FindResult {
ext: string;
code: string;
}
async function find(
rootUrl: string,
pathWithoutExt: string,
): Promise<FindResult | undefined> {
const cached = downloadedFiles.get(pathWithoutExt);
if (cached) return cached;
for (const ext of resolveExtensions) {
const res = await fetch(`${rootUrl}/${pathWithoutExt}.${ext}`);
if (!res.ok) continue;
log.step(`downloaded ${pathWithoutExt}.${ext}`);
const result: FindResult = {
ext,
code: await res.text(),
};
downloadedFiles.set(pathWithoutExt, result);
return result;
}
}
/**
* Only look for TypeScript extensions
*/
const resolveExtensions = ['tsx', 'ts'];
+165
View File
@@ -0,0 +1,165 @@
import * as process from 'node:process';
import path from 'node:path';
import {
intro,
confirm,
isCancel,
cancel,
spinner,
log,
note,
} from '@clack/prompts';
import picocolors from 'picocolors';
import { sync } from 'cross-spawn';
import { getPackageManager } from '@/utils/get-package-manager';
import { exists } from '@/utils/fs';
import { isSrc } from '@/utils/is-src';
import { type Config } from '@/config';
import {
getOutputPath,
toReferencePath,
transformReferences,
} from '@/utils/transform-references';
import { createEmptyProject } from '@/utils/typescript';
export type Awaitable<T> = T | Promise<T>;
export interface PluginContext extends Config {
src: boolean;
/**
* Original path in `files` - Transformed output path
*/
outFileMap: Map<string, string>;
}
export interface Plugin {
dependencies: string[];
files: (ctx: PluginContext) => Awaitable<Record<string, string>>;
instructions: (ctx: PluginContext) => Awaitable<
(
| {
type: 'code';
title?: string;
code: string;
}
| {
type: 'text';
text: string;
}
)[]
>;
transform?: (ctx: PluginContext) => Awaitable<void>;
}
export async function init(plugin: Plugin, config: Config = {}): Promise<void> {
intro(
picocolors.bgCyan(picocolors.black(picocolors.bold('Installing Plugins'))),
);
const ctx: PluginContext = {
src: await isSrc(),
outFileMap: new Map(),
...config,
};
const files = await plugin.files(ctx);
const project = createEmptyProject();
for (const [name, content] of Object.entries(files)) {
const file = getOutputPath(name, ctx);
ctx.outFileMap.set(name, file);
log.step(picocolors.green(`Writing ${file} ★`));
if (await exists(file)) {
const value = await confirm({
message: `${file} already exists`,
active: 'Override',
inactive: 'Skip',
});
if (isCancel(value)) {
cancel('Operation cancelled.');
process.exit(0);
}
if (!value) continue;
}
const sourceFile = project.createSourceFile(file, content, {
overwrite: true,
});
await transformReferences(
sourceFile,
{
alias: ctx.src ? 'src' : 'root',
relativeTo: path.dirname(file),
},
(resolved) => {
if (resolved.type !== 'file') return;
return toReferencePath(file, getOutputPath(resolved.path, ctx));
},
);
await sourceFile.save();
}
if (plugin.dependencies.length > 0) {
const manager = await getPackageManager();
const value = await confirm({
message: `This plugin contains additional dependencies, do you want to install them? (detected: ${manager})`,
});
if (isCancel(value)) {
cancel('Operation cancelled.');
process.exit(0);
}
if (value) {
const spin = spinner();
spin.start('Installing dependencies');
sync(`${manager} install ${plugin.dependencies.join(' ')}`, {
stdio: 'ignore',
});
spin.stop('Successfully installed.');
}
}
if (plugin.transform) {
const value = await confirm({
message:
'This plugin contains changes to your files, do you want to apply them?',
});
if (isCancel(value)) {
cancel('Operation cancelled.');
process.exit(0);
}
if (value) {
await plugin.transform(ctx);
note(
`You can format the output with Prettier or other code formating tools
prettier . --write`,
picocolors.bold(picocolors.green('Changes Applied')),
);
}
}
const instructions = await plugin.instructions(ctx);
for (const text of instructions) {
if (text.type === 'text') {
log.message(text.text, {
symbol: '○',
});
}
if (text.type === 'code') {
note(text.code, text.title);
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import fs from 'node:fs/promises';
import { isSrc } from '@/utils/is-src';
export interface Config {
aliases?: {
/**
* Path for importing `cn` utility.
*
* Can be from Shadcn UI or your own `cn` function (Tailwind CSS supported)
*
* Be careful to **not** include file extensions.
*/
cn?: string;
uiDir?: string;
componentsDir?: string;
libDir?: string;
};
commands?: {
/**
* command to format output code automatically
*/
format?: string;
};
}
const src = await isSrc();
export const defaultConfig = {
aliases: {
cn: src ? './src/utils/cn' : './utils/cn',
componentsDir: src ? './src/components' : './components',
uiDir: src ? './src/components/ui' : './components/ui',
libDir: src ? './src/lib' : './lib',
},
} satisfies Config;
export async function loadConfig(file = './cli.json'): Promise<Config> {
try {
const content = await fs.readFile(file);
return JSON.parse(content.toString()) as Config;
} catch (_) {
return {};
}
}
export async function initConfig(file = './cli.json'): Promise<void> {
await fs.writeFile(file, JSON.stringify(defaultConfig, null, 2));
}
+5
View File
@@ -0,0 +1,5 @@
import { type templates } from '../scripts/sync';
export const generated: typeof templates;
export const components: string[];
+80
View File
@@ -0,0 +1,80 @@
import { Command } from 'commander';
import picocolors from 'picocolors';
import { isCancel, multiselect, outro } from '@clack/prompts';
import { init } from '@/commands/init';
import { add } from '@/commands/add';
import { initConfig, loadConfig } from '@/config';
import { plugins } from '@/plugins';
import { components } from '@/generated';
import packageJson from '../package.json';
const program = new Command();
program
.name('fumadocs')
.description('CLI to setup Fumadocs')
.version(packageJson.version);
program
.command('i')
.description('init a config for Fumadocs CLI')
.action(async () => {
await initConfig();
console.log(picocolors.green('Successful: ./cli.json'));
});
program
.command('init')
.description('init a new plugin to your docs')
.argument('<string>', 'plugin name')
.option('--config <string>')
.action(async (str: string, { config }) => {
const loadedConfig = await loadConfig(config as string | undefined);
const plugin = str in plugins ? plugins[str] : undefined;
if (!plugin) throw new Error(`Plugin not found: ${str}`);
await init(plugin, loadedConfig);
});
program
.command('add')
.description('add a new component to your docs')
.argument('[components...]', 'components to download')
.option(
'--branch <string>',
'the Git branch name of Fumadocs repo to download from (e.g. main)',
)
.option('--config <string>')
.action(
async (
str: string[],
{ config, branch }: { config?: string; branch?: string },
) => {
let target = str;
if (str.length === 0) {
const value = await multiselect({
message: 'Select components to install',
options: components.map((c) => ({
label: c,
value: c,
})),
});
if (isCancel(value)) {
outro('Ended');
return;
}
target = value as string[];
}
const loadedConfig = await loadConfig(config);
for (const name of target) {
await add(name, branch ?? 'main', loadedConfig);
}
},
);
program.parse();
+83
View File
@@ -0,0 +1,83 @@
import path from 'node:path';
import picocolors from 'picocolors';
import { type Plugin } from '@/commands/init';
import { generated } from '@/generated';
import { transformLayoutConfig } from '@/utils/transform-layout-config';
import { resolveAppPath } from '@/utils/is-src';
import { moveFiles } from '@/utils/move-files';
import { isRelative } from '@/utils/fs';
import { transformSourceI18n } from '@/utils/transform-source-i18n';
import { defaultConfig } from '@/config';
import { createEmptyProject } from '@/utils/typescript';
export const i18nPlugin: Plugin = {
files: () => ({
'lib/i18n.ts': generated['lib/i18n'],
'middleware.ts': generated.middleware,
}),
dependencies: [],
instructions: () => [
{
type: 'text',
text: `Moved the ./app files to a [lang] route group.
Make sure to update the params of page.tsx and route.ts (if necessary):`,
},
{
type: 'code',
title: 'page.tsx',
code: `
export default function Page({
params,
}: {
${picocolors.underline(picocolors.bold('params: { lang: string; slug?: string[] };'))}
})
`.trim(),
},
{
type: 'text',
text: 'Update the usages to `source` with:',
},
{
type: 'code',
title: 'page.tsx',
code: `const page = source.getPage(params.slug, params.lang);
const pages = source.getPage(params.lang);`,
},
{
type: 'code',
title: 'layout.tsx',
code: `const tree = source.pageTree[params.lang];`,
},
],
async transform(ctx) {
const project = createEmptyProject();
await Promise.all([
transformLayoutConfig(
project,
resolveAppPath('./app/layout.config.tsx', ctx.src),
),
transformSourceI18n(
project,
path.join(
ctx.aliases?.libDir ?? defaultConfig.aliases.libDir,
'source.ts',
),
ctx,
),
]);
await moveFiles(
resolveAppPath('./app', ctx.src),
resolveAppPath('./app/[lang]', ctx.src),
(v) => {
return (
path.basename(v, path.extname(v)) !== 'layout.config' &&
!isRelative('./app/api', v)
);
},
project,
ctx.src,
);
},
};
+10
View File
@@ -0,0 +1,10 @@
import { ogImagePlugin } from '@/plugins/og-image';
import { i18nPlugin } from '@/plugins/i18n';
import { openapiPlugin } from '@/plugins/openapi';
import { type Plugin } from '@/commands/init';
export const plugins: Record<string, Plugin> = {
'og-image': ogImagePlugin,
i18n: i18nPlugin,
openapi: openapiPlugin,
};
+57
View File
@@ -0,0 +1,57 @@
import picocolors from 'picocolors';
import { generated } from '@/generated';
import { type Plugin, type PluginContext } from '@/commands/init';
import { exists } from '@/utils/fs';
import { resolveAppPath } from '@/utils/is-src';
import { getOutputPath } from '@/utils/transform-references';
function isI18nEnabled(ctx: PluginContext): Promise<boolean> {
return exists(getOutputPath('lib/i18n.ts', ctx));
}
export const ogImagePlugin: Plugin = {
files: async (ctx) => {
const route = (await isI18nEnabled(ctx))
? 'app/[lang]/docs-og/[...slug]/route.tsx'
: 'app/docs-og/[...slug]/route.tsx';
return {
'lib/metadata.ts': generated['lib/metadata'],
[route]: generated['app/docs-og/[...slug]/route'],
};
},
dependencies: [],
instructions: (ctx) => [
{
type: 'text',
text: picocolors.cyanBright(picocolors.bold('Import the utils like:')),
},
{
type: 'code',
title: 'ts',
code: 'import { metadataImage } from "@/lib/metadata";',
},
{
type: 'text',
text: picocolors.cyanBright(
picocolors.bold('Add the images to your metadata:'),
),
},
{
type: 'code',
title: resolveAppPath('app/docs/[[...slug]]/page.tsx', ctx.src),
code: `
export function generateMetadata({ params }: { params: { slug?: string[] } }) {
const page = source.getPage(params.slug);
if (!page) notFound();
${picocolors.bold(picocolors.underline('return metadataImage.withImage(page.slugs, {'))}
title: page.data.title,
description: page.data.description,
});
}
`.trim(),
},
],
};
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { StructureKind } from 'ts-morph';
import { type Plugin } from '@/commands/init';
import { createEmptyProject } from '@/utils/typescript';
import { generated } from '@/generated';
import { getPackageManager } from '@/utils/get-package-manager';
import { transformTailwind } from '@/utils/transform-tailwind';
import { type Config, defaultConfig } from '@/config';
export const openapiPlugin: Plugin = {
files: () => ({
'scripts/generate-docs.mjs': generated['scripts/generate-docs'],
}),
dependencies: ['fumadocs-openapi', 'rimraf'],
async transform(ctx) {
await Promise.all([
transformSource(ctx),
transformTailwind({
addContents: [`./node_modules/fumadocs-openapi/dist/**/*.js`],
}),
addScript(),
]);
},
instructions: async () => [
{
type: 'text',
text: `I've made some changes to your Tailwind CSS config.
You can add the APIPage component to your page.tsx:`,
},
{
type: 'code',
title: 'page.tsx',
code: `import defaultMdxComponents from 'fumadocs-ui/mdx';
import { openapi } from '@/lib/source';
<MDX
components={{
...defaultMdxComponents,
APIPage: openapi.APIPage,
}}
/>;`,
},
{
type: 'text',
text: `Paste your OpenAPI schema to ./openapi.json, and use this script to generate docs:`,
},
{
type: 'code',
title: 'Terminal',
code: `${await getPackageManager()} run build:docs`,
},
],
};
async function addScript(): Promise<void> {
const content = await fs.readFile('package.json');
const parsed = JSON.parse(content.toString()) as Record<string, unknown>;
if (typeof parsed.scripts !== 'object') return;
parsed.scripts ??= {};
Object.assign(parsed.scripts ?? {}, {
'build:docs': 'node ./scripts/generate-docs.mjs',
});
await fs.writeFile('package.json', JSON.stringify(parsed, null, 2));
}
async function transformSource(config: Config): Promise<void> {
const source = path.join(
config.aliases?.libDir ?? defaultConfig.aliases.libDir,
'source.ts',
);
const project = createEmptyProject();
const file = project.createSourceFile(source, undefined, {
overwrite: true,
});
file.addImportDeclaration({
kind: StructureKind.ImportDeclaration,
namedImports: ['createOpenAPI'],
moduleSpecifier: 'fumadocs-openapi/server',
});
file.addStatements(`export const openapi = createOpenAPI();`);
await file.save();
}
+15
View File
@@ -0,0 +1,15 @@
import fs from 'node:fs/promises';
import path from 'node:path';
export async function exists(pathLike: string): Promise<boolean> {
try {
await fs.access(pathLike);
return true;
} catch {
return false;
}
}
export function isRelative(from: string, to: string): boolean {
return !path.relative(from, to).startsWith(`..${path.sep}`);
}
@@ -0,0 +1,9 @@
import { detect } from 'package-manager-detector';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
export async function getPackageManager(): Promise<PackageManager> {
const result = await detect();
return result?.name ?? 'npm';
}

Some files were not shown because too many files have changed in this diff Show More