Core: Support referencing original page/meta from page tree nodes

This commit is contained in:
fuma-nama
2024-09-17 23:26:02 +08:00
parent b0bd94b845
commit 367f4c3f29
25 changed files with 346 additions and 87 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': patch
---
Improve Root Toggle component
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support referencing original page/meta from page tree nodes
+5 -5
View File
@@ -16,7 +16,7 @@ import { TypeTable } from 'fumadocs-ui/components/type-table';
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
import Preview from '@/components/preview';
import { createMetadata, metadataImage } from '@/utils/metadata';
import { openapi, utils } from '@/app/source';
import { openapi, source } from '@/app/source';
import { Wrapper } from '@/components/preview/wrapper';
interface Param {
@@ -28,7 +28,7 @@ export default function Page({
}: {
params: Param;
}): React.ReactElement {
const page = utils.getPage(params.slug);
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -75,7 +75,7 @@ export default function Page({
}}
/>
{page.data.index ? (
<DocsCategory page={page} pages={utils.getPages()} />
<DocsCategory page={page} pages={source.getPages()} />
) : null}
</DocsBody>
</DocsPage>
@@ -83,7 +83,7 @@ export default function Page({
}
export function generateMetadata({ params }: { params: Param }): Metadata {
const page = utils.getPage(params.slug);
const page = source.getPage(params.slug);
if (!page) notFound();
@@ -102,5 +102,5 @@ export function generateMetadata({ params }: { params: Param }): Metadata {
}
export function generateStaticParams(): Param[] {
return utils.generateParams();
return source.generateParams();
}
+58 -17
View File
@@ -1,11 +1,16 @@
import { type DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
import { AlbumIcon, BookIcon, Heart, LayoutTemplateIcon } from 'lucide-react';
import Image from 'next/image';
import { RootToggle } from 'fumadocs-ui/components/layout/root-toggle';
import {
type Option,
RootToggle,
} from 'fumadocs-ui/components/layout/root-toggle';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { type PageTree } from 'fumadocs-core/server';
import { Slot } from '@radix-ui/react-slot';
import { FumadocsIcon } from '@/app/layout.client';
import Logo from '@/public/logo.png';
import { utils } from '@/app/source';
import { source } from '@/app/source';
import { modes } from '@/utils/modes';
export const baseOptions: BaseLayoutProps = {
@@ -58,9 +63,42 @@ export const baseOptions: BaseLayoutProps = {
],
};
function getItems(): Option[] {
const options: Option[] = [];
function traverse(node: PageTree.Node): void {
if (node.type === 'folder' && node.root && node.index) {
const meta = source.getNodeMeta(node);
if (!meta) return;
options.push({
url: node.index.url,
icon: (
<Slot
className="mb-auto bg-gradient-to-t from-fd-background/80 p-1 [&_svg]:size-5"
style={{
color: `hsl(var(--${meta.file.dirname}-color))`,
backgroundColor: `hsl(var(--${meta.file.dirname}-color)/.3)`,
}}
>
{node.icon}
</Slot>
),
description: meta.data.description,
title: node.name,
});
}
if (node.type === 'folder') node.children.forEach(traverse);
}
source.pageTree.children.forEach(traverse);
return options;
}
export const docsOptions: DocsLayoutProps = {
...baseOptions,
tree: utils.pageTree,
tree: source.pageTree,
nav: {
...baseOptions.nav,
transparentMode: 'none',
@@ -70,20 +108,23 @@ export const docsOptions: DocsLayoutProps = {
sidebar: {
banner: (
<RootToggle
options={modes.map((mode) => ({
url: `/docs/${mode.param}`,
icon: (
<mode.icon
className="size-9 shrink-0 rounded-md bg-gradient-to-t from-fd-background/80 p-1.5"
style={{
backgroundColor: `hsl(var(--${mode.param}-color)/.3)`,
color: `hsl(var(--${mode.param}-color))`,
}}
/>
),
title: mode.name,
description: mode.description,
}))}
options={
getItems() ||
modes.map((mode) => ({
url: `/docs/${mode.param}`,
icon: (
<mode.icon
className="size-9 shrink-0 rounded-md bg-gradient-to-t from-fd-background/80 p-1.5"
style={{
backgroundColor: `hsl(var(--${mode.param}-color)/.3)`,
color: `hsl(var(--${mode.param}-color))`,
}}
/>
),
title: mode.name,
description: mode.description,
}))
}
/>
),
},
+2 -2
View File
@@ -1,6 +1,6 @@
import type { MetadataRoute } from 'next';
import { baseUrl } from '@/utils/metadata';
import { utils } from '@/app/source';
import { source } from '@/app/source';
export default function sitemap(): MetadataRoute.Sitemap {
const url = (path: string): string => new URL(path, baseUrl).toString();
@@ -21,7 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: 'monthly',
priority: 0.8,
},
...utils.getPages().map<MetadataRoute.Sitemap[number]>((page) => ({
...source.getPages().map<MetadataRoute.Sitemap[number]>((page) => ({
url: url(page.url),
lastModified: page.data.lastModified
? new Date(page.data.lastModified)
+9 -6
View File
@@ -3,14 +3,17 @@ import type { InferMetaType, InferPageType } from 'fumadocs-core/source';
import { loader } from 'fumadocs-core/source';
import { icons } from 'lucide-react';
import { attachFile, createOpenAPI } from 'fumadocs-openapi/server';
import { create } from '@/components/ui/icon';
import { createElement } from 'react';
import { IconContainer } from '@/components/ui/icon';
import { meta, docs, blog as blogPosts } from '@/.source';
export const utils = loader({
export const source = loader({
baseUrl: '/docs',
icon(icon) {
if (icon && icon in icons)
return create({ icon: icons[icon as keyof typeof icons] });
return createElement(IconContainer, {
icon: icons[icon as keyof typeof icons],
});
},
source: createMDXSource(docs, meta),
pageTree: {
@@ -23,7 +26,7 @@ export const blog = loader({
source: createMDXSource(blogPosts, []),
});
export const openapi = createOpenAPI({});
export const openapi = createOpenAPI();
export type Page = InferPageType<typeof utils>;
export type Meta = InferMetaType<typeof utils>;
export type Page = InferPageType<typeof source>;
export type Meta = InferMetaType<typeof source>;
+2 -2
View File
@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server';
import { type DocumentRecord } from 'fumadocs-core/search/algolia';
import { utils } from '@/app/source';
import { source } from '@/app/source';
export const revalidate = false;
export function GET(): Response {
const results: DocumentRecord[] = [];
const pages = utils.getPages();
const pages = source.getPages();
for (const page of pages) {
results.push({
+12 -3
View File
@@ -1,13 +1,22 @@
import type { LucideIcon } from 'lucide-react';
import { TerminalIcon } from 'lucide-react';
import { type HTMLAttributes } from 'react';
import { cn } from '@/utils/cn';
export function create({
export function IconContainer({
icon: Icon,
}: {
...props
}: HTMLAttributes<HTMLDivElement> & {
icon?: LucideIcon;
}): React.ReactElement {
return (
<div className="rounded-md border bg-gradient-to-b from-fd-secondary p-1 shadow-sm">
<div
{...props}
className={cn(
'rounded-md border bg-gradient-to-b from-fd-secondary p-1 shadow-sm',
props.className,
)}
>
{Icon ? <Icon /> : <TerminalIcon />}
</div>
);
+92
View File
@@ -0,0 +1,92 @@
---
title: User Guide
description: The CLI tool that automates setups and installing components.
---
## Installation
The CLI doesn't need to install any extra packages, but It needs Fumadocs Core for minimal setup.
You can initialize a config for CLI.
```package-install
npx fumadocs config
```
It defines the output paths of components.
### Components
Select and install components.
```package-install
npx fumadocs add
```
You can pass component names.
```package-install
npx fumadocs add banner files
```
#### How the magic works?
The CLI fetches the latest version of component from the GitHub repository of Fumadocs.
When you install the component, it is guaranteed to be up-to-date.
In addition, it also transforms import paths.
Make sure to use the latest version of CLI
> This is highly Inspired by Shadcn UI.
### Tree
Generate files tree for Fumadocs UI `Files` component, using the `tree` command from your terminal.
```package-install
npx fumadocs tree ./my-dir ./output.tsx
```
You can output MDX file too:
```package-install
npx fumadocs tree ./my-dir ./output.mdx
```
See help for further details:
```package-install
npx fumadocs tree -h
```
#### Example Output
```tsx title="output.tsx"
import { File, Folder, Files } from 'fumadocs-ui/components/files';
export default (
<Files>
<Folder name="app">
<File name="layout.tsx" />
<File name="page.tsx" />
<File name="global.css" />
</Folder>
<Folder name="components">
<File name="button.tsx" />
<File name="tabs.tsx" />
<File name="dialog.tsx" />
</Folder>
<File name="package.json" />
</Files>
);
```
### Initialize Features
Some features of Fumadocs require copying code to get started, it is similar to `codemod` but for configuring features automatically.
```package-install
npx fumadocs init
```
Please note that some features may change your existing code, make sure to commit your changes to Git before running it.
+6
View File
@@ -0,0 +1,6 @@
{
"root": true,
"title": "Fumadocs CLI",
"description": "The CLI tool for docs automation",
"icon": "Terminal"
}
+3 -1
View File
@@ -1,5 +1,7 @@
{
"title": "headless",
"title": "Fumadocs Core",
"description": "The headless library",
"icon": "Library",
"root": true,
"pages": [
"---Guide---",
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
```
During a production build (`next build`), Fumadocs MDX will output a manifest file in `.source/manifest.json`.
You can use it to collect build infos.
You can use it to collect build information.
### What is It?
+3 -1
View File
@@ -1,5 +1,7 @@
{
"title": "mdx",
"title": "Fumadocs MDX",
"description": "The official content source",
"icon": "Pencil",
"root": true,
"pages": [
"---Guide---",
+3
View File
@@ -0,0 +1,3 @@
{
"pages": ["ui", "headless", "mdx", "cli"]
}
@@ -55,6 +55,8 @@ source.getPage(params.slug, params.lang);
source.getPages(params.lang);
```
See [Source API](/docs/headless/source-api) for other usages.
### Middleware
Create a middleware that redirects users to appropriate locale.
@@ -117,7 +119,7 @@ Make sure to update references to `source` and configure Static Site Generation
### Writing Documents
see [Page Conventions](/docs/ui/page-conventions#internationalization) to learn how to organize your documents.
see [Page Conventions](/docs/ui/page-conventions#internationalization) to learn how to organize your documents.
### Search
+3 -1
View File
@@ -1,5 +1,7 @@
{
"title": "ui",
"title": "Framework",
"description": "The docs framework",
"icon": "Building2",
"root": true,
"pages": [
"---Guide---",
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@next/env": "^14.2.11",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@resvg/resvg-js": "^2.6.2",
"@shikijs/transformers": "^1.17.5",
+6
View File
@@ -3,6 +3,7 @@ import {
defineDocs,
defineCollections,
frontmatterSchema,
metaSchema,
} from 'fumadocs-mdx/config';
import { rehypeCodeDefaultOptions } from 'fumadocs-core/mdx-plugins';
import { transformerTwoslash } from 'fumadocs-twoslash';
@@ -28,6 +29,11 @@ export const { docs, meta } = defineDocs({
method: z.string().optional(),
}),
},
meta: {
schema: metaSchema.extend({
description: z.string().optional(),
}),
},
});
export const blog = defineCollections({
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next/types';
import { createMetadataImage } from 'fumadocs-core/server';
import { utils } from '@/app/source';
import { source } from '@/app/source';
export function createMetadata(override: Metadata): Metadata {
return {
@@ -25,11 +25,11 @@ export function createMetadata(override: Metadata): Metadata {
}
export const baseUrl =
process.env.NODE_ENV === 'development'
process.env.NODE_ENV === 'development' || !process.env.VERCEL_URL
? new URL('http://localhost:3000')
: new URL(`https://${process.env.VERCEL_URL!}`);
: new URL(`https://${process.env.VERCEL_URL}`);
export const metadataImage = createMetadataImage({
source: utils,
source,
imageRoute: 'og',
});
+24 -6
View File
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { Command } from 'commander';
import picocolors from 'picocolors';
import { isCancel, multiselect, outro } from '@clack/prompts';
import { isCancel, multiselect, outro, select } from '@clack/prompts';
import { init } from '@/commands/init';
import { add } from '@/commands/add';
import { initConfig, loadConfig } from '@/config';
@@ -34,15 +34,33 @@ program
program
.command('init')
.description('init a new plugin to your docs')
.argument('<string>', 'plugin name')
.argument('[string]', 'plugin name')
.option('--config <string>')
.action(async (str: string, { config }) => {
.action(async (str: string | undefined, { 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}`);
if (str) {
const plugin = str in plugins ? plugins[str] : undefined;
if (!plugin) throw new Error(`Plugin not found: ${str}`);
await init(plugin, loadedConfig);
await init(plugin, loadedConfig);
return;
}
const value = await select({
message: 'Select components to install',
options: Object.keys(plugins).map((c) => ({
label: c,
value: c,
})),
});
if (isCancel(value)) {
outro('Ended');
return;
}
await init(plugins[value as keyof typeof plugins], loadedConfig);
});
program
+10
View File
@@ -13,6 +13,10 @@ export interface Item {
url: string;
external?: boolean;
icon?: ReactElement;
$ref?: {
file: string;
};
}
export interface Separator {
@@ -23,9 +27,15 @@ export interface Separator {
export interface Folder {
/**
* Optional id to be attached to folders
*
* @deprecated use `$ref`
*/
id?: string;
$ref?: {
metaFile?: string;
};
type: 'folder';
name: ReactNode;
root?: boolean;
+67 -26
View File
@@ -10,7 +10,7 @@ import type { MetaData, PageData, UrlFn } from './types';
import type { BuildPageTreeOptions } from './page-tree-builder';
import { createPageTreeBuilder } from './page-tree-builder';
import { type FileInfo } from './path';
import type { File, PageFile, Storage } from './file-system';
import type { MetaFile, PageFile, Storage } from './file-system';
export interface LoaderConfig {
source: SourceConfig;
@@ -80,9 +80,8 @@ export interface LoaderOutput<Config extends LoaderConfig> {
pageTree: Config['i18n'] extends true
? Record<string, PageTree.Root>
: PageTree.Root;
_i18n?: I18nConfig;
files: File[];
_i18n?: I18nConfig;
/**
* Get list of pages from language, empty if language hasn't specified
@@ -101,6 +100,14 @@ export interface LoaderOutput<Config extends LoaderConfig> {
language?: string,
) => Page<Config['source']['pageData']> | undefined;
getNodePage: (
node: PageTree.Item,
) => Page<Config['source']['pageData']> | undefined;
getNodeMeta: (
node: PageTree.Folder,
) => Meta<Config['source']['metaData']> | undefined;
/**
* generate static params for Next.js SSG
*/
@@ -113,35 +120,53 @@ export interface LoaderOutput<Config extends LoaderConfig> {
) => (Record<TSlug, string[]> & Record<TLang, string>)[];
}
function buildPageMap(
function indexPages(
storage: Storage,
getUrl: UrlFn,
languages: string[] = [],
): Map<string, Map<string, Page>> {
const map = new Map<string, Map<string, Page>>();
): {
// locale -> (slugs -> page[])
i18n: Map<string, Map<string, Page>>;
pathToPage: Map<string, Page>;
pathToMeta: Map<string, Meta>;
} {
const i18n = new Map<string, Map<string, Page>>();
const pages = new Map<string, Page>();
const metas = new Map<string, Meta>();
const defaultMap = new Map<string, Page>();
map.set('', defaultMap);
i18n.set('', defaultMap);
for (const file of storage.list()) {
if (file.format !== 'page' || file.file.locale) continue;
const page = fileToPage(file, getUrl);
if (file.format === 'meta') metas.set(file.file.path, fileToMeta(file));
defaultMap.set(page.slugs.join('/'), page);
if (file.format === 'page') {
const page = fileToPage(file, getUrl, file.file.locale);
pages.set(file.file.path, page);
for (const lang of languages) {
const langMap = map.get(lang) ?? new Map<string, Page>();
if (file.file.locale) continue;
defaultMap.set(page.slugs.join('/'), page);
const localized = storage.read(
`${file.file.flattenedPath}.${lang}`,
'page',
);
const localizedPage = fileToPage(localized ?? file, getUrl, lang);
langMap.set(localizedPage.slugs.join('/'), localizedPage);
map.set(lang, langMap);
for (const lang of languages) {
const langMap = i18n.get(lang) ?? new Map<string, Page>();
const localized = storage.read(
`${file.file.flattenedPath}.${lang}`,
'page',
);
const localizedPage = fileToPage(localized ?? file, getUrl, lang);
langMap.set(localizedPage.slugs.join('/'), localizedPage);
i18n.set(lang, langMap);
}
}
}
return map;
return {
i18n,
pathToPage: pages,
pathToMeta: metas,
};
}
export function createGetUrl(baseUrl: string): UrlFn {
@@ -195,7 +220,7 @@ function createOutput({
getSlugs: slugsFn,
},
);
const i18nMap = buildPageMap(storage, getUrl, i18n?.languages);
const walker = indexPages(storage, getUrl, i18n?.languages);
const builder = createPageTreeBuilder();
const pageTree =
i18n === undefined
@@ -216,14 +241,13 @@ function createOutput({
return {
_i18n: i18n,
pageTree: pageTree as LoaderOutput<LoaderConfig>['pageTree'],
files: storage.list(),
getPages(language = i18n?.defaultLanguage ?? '') {
return Array.from(i18nMap.get(language)?.values() ?? []);
return Array.from(walker.i18n.get(language)?.values() ?? []);
},
getLanguages() {
const list: LanguageEntry[] = [];
for (const [language, pages] of i18nMap) {
for (const [language, pages] of walker.i18n) {
if (language === '') continue;
list.push({
@@ -235,7 +259,17 @@ function createOutput({
return list;
},
getPage(slugs = [], language = i18n?.defaultLanguage ?? '') {
return i18nMap.get(language)?.get(slugs.join('/'));
return walker.i18n.get(language)?.get(slugs.join('/'));
},
getNodeMeta(node) {
if (!node.$ref?.metaFile) return;
return walker.pathToMeta.get(node.$ref.metaFile);
},
getNodePage(node) {
if (!node.$ref?.file) return;
return walker.pathToPage.get(node.$ref.file);
},
// @ts-expect-error -- ignore this
generateParams(slug, lang) {
@@ -248,13 +282,20 @@ function createOutput({
);
}
return Array.from(i18nMap.get('')?.values() ?? []).map((page) => ({
return Array.from(walker.i18n.get('')?.values() ?? []).map((page) => ({
[slug ?? 'slug']: page.slugs,
}));
},
};
}
function fileToMeta<Data = MetaData>(file: MetaFile): Meta<Data> {
return {
file: file.file,
data: file.data.data as Data,
};
}
function fileToPage<Data = PageData>(
file: PageFile,
getUrl: UrlFn,
+12 -6
View File
@@ -18,11 +18,11 @@ interface PageTreeBuilderContext {
export interface BuildPageTreeOptions {
/**
* Attach the `folder.id` property
* Remove references to the file path of original nodes (`$ref`)
*
* @defaultValue false
*/
attachFolderIds?: boolean;
noRef?: boolean;
attachFile?: (node: PageTree.Item, file?: PageFile) => PageTree.Item;
attachFolder?: (
@@ -211,12 +211,13 @@ function buildFolderNode(
defaultOpen: metadata?.defaultOpen,
index,
children,
$ref: !ctx.options.noRef
? {
metaFile: meta?.file.path,
}
: undefined,
};
if (ctx.options.attachFolderIds) {
node.id = folder.file.flattenedPath;
}
return removeUndefined(
ctx.options.attachFolder?.(node, folder, meta) ?? node,
);
@@ -245,6 +246,11 @@ function buildFileNode(
name: localized.data.data.title,
icon: ctx.options.resolveIcon?.(localized.data.data.icon),
url: ctx.options.getUrl(localized.data.slugs, urlLocale),
$ref: !ctx.options.noRef
? {
file: localized.file.path,
}
: undefined,
};
return removeUndefined(ctx.options.attachFile?.(item, file) ?? item);
@@ -8,7 +8,7 @@ import { isActive } from '@/utils/shared';
import { useSidebar } from '@/contexts/sidebar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
interface Option {
export interface Option {
/**
* Redirect URL of the folder, usually the index page
*/
@@ -30,8 +30,7 @@ export function RootToggle({
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selected =
options.find((item) => isActive(item.url, pathname, true)) ?? options[0];
const selected = options.find((item) => isActive(item.url, pathname, true));
const onClick = useCallback(() => {
closeOnRedirect.current = false;
@@ -42,12 +41,15 @@ export function RootToggle({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
{...props}
{...selected?.props}
className={cn(
'-mx-2 flex flex-row items-center gap-2.5 rounded-lg p-2 hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
'-mx-1 flex flex-row items-center gap-2.5 rounded-lg p-1 hover:bg-fd-accent/50 hover:text-fd-accent-foreground',
props.className,
selected?.props?.className,
)}
>
<Item {...selected} />
{selected ? <Item {...selected} /> : null}
<ChevronDown className="size-4 text-fd-muted-foreground md:me-1.5" />
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] overflow-hidden p-0">
+3
View File
@@ -56,6 +56,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.1.1
version: 1.1.1(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot':
specifier: ^1.1.0
version: 1.1.0(@types/[email protected])([email protected])
'@radix-ui/react-tooltip':
specifier: ^1.1.2
version: 1.1.2(@types/[email protected])(@types/[email protected])([email protected]([email protected]))([email protected])