mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Support multiple() for multiple sources in loader()
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
Support `multiple()` for multiple sources in same `loader()`
|
||||
@@ -26,9 +26,10 @@ import * as path from 'node:path';
|
||||
import { Banner } from 'fumadocs-ui/components/banner';
|
||||
import { Installation } from '@/components/preview/installation';
|
||||
import { Customisation } from '@/components/preview/customisation';
|
||||
import { DocsPage } from 'fumadocs-ui/page';
|
||||
import { DocsBody, DocsPage } from 'fumadocs-ui/page';
|
||||
import { NotFound } from '@/components/not-found';
|
||||
import { getSuggestions } from '@/app/docs/[...slug]/suggestions';
|
||||
import { APIPage } from 'fumadocs-openapi/ui';
|
||||
|
||||
function PreviewRenderer({ preview }: { preview: string }): ReactNode {
|
||||
if (preview && preview in Preview) {
|
||||
@@ -52,9 +53,19 @@ export default async function Page(props: PageProps<'/docs/[...slug]'>) {
|
||||
<NotFound getSuggestions={() => getSuggestions(params.slug.join(' '))} />
|
||||
);
|
||||
|
||||
const preview = page.data.preview;
|
||||
const { body: Mdx, toc, lastModified } = page.data;
|
||||
const isVirtual = page.data.info.fullPath.startsWith('virtual:');
|
||||
if (page.data.type === 'openapi') {
|
||||
return (
|
||||
<DocsPage>
|
||||
<h1 className="text-[1.75em] font-semibold">{page.data.title}</h1>
|
||||
<p className="text-fd-muted-foreground mb-6">{page.data.description}</p>
|
||||
<DocsBody>
|
||||
<APIPage {...page.data.getAPIPageProps()} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
const { body: Mdx, toc, lastModified, preview } = page.data;
|
||||
|
||||
return (
|
||||
<DocsPage
|
||||
@@ -68,15 +79,13 @@ export default async function Page(props: PageProps<'/docs/[...slug]'>) {
|
||||
<p className="text-lg text-fd-muted-foreground mb-2">
|
||||
{page.data.description}
|
||||
</p>
|
||||
{!isVirtual && (
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
|
||||
<ViewOptions
|
||||
markdownUrl={`${page.url}.mdx`}
|
||||
githubUrl={`https://github.com/${owner}/${repo}/blob/dev/apps/docs/content/docs/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<LLMCopyButton markdownUrl={`${page.url}.mdx`} />
|
||||
<ViewOptions
|
||||
markdownUrl={`${page.url}.mdx`}
|
||||
githubUrl={`https://github.com/${owner}/${repo}/blob/dev/apps/docs/content/docs/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="prose flex-1 text-fd-foreground/90">
|
||||
{preview ? <PreviewRenderer preview={preview} /> : null}
|
||||
<Mdx
|
||||
@@ -127,7 +136,7 @@ export default async function Page(props: PageProps<'/docs/[...slug]'>) {
|
||||
/>
|
||||
{page.data.index ? <DocsCategory url={page.url} /> : null}
|
||||
</div>
|
||||
{!isVirtual && <Feedback onRateAction={onRateAction} />}
|
||||
<Feedback onRateAction={onRateAction} />
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import { getLLMText } from '@/lib/get-llm-text';
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const scan = source
|
||||
.getPages()
|
||||
.filter((file) => file.slugs[0] !== 'openapi')
|
||||
.map(getLLMText);
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response(scanned.join('\n\n'));
|
||||
|
||||
@@ -23,7 +23,8 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
...source.getPages().map((page) => {
|
||||
...source.getPages().flatMap((page) => {
|
||||
if (page.data.type === 'openapi') return [];
|
||||
const { lastModified } = page.data;
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,28 +5,28 @@ import { getBreadcrumbItems } from 'fumadocs-core/breadcrumb';
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(): Promise<Response> {
|
||||
const results: OramaDocument[] = [];
|
||||
const pages = source.getPages();
|
||||
const results = pages
|
||||
.filter((page) => page.slugs[0] !== 'openapi')
|
||||
.map((page) => {
|
||||
const { structuredData } = page.data;
|
||||
const items = getBreadcrumbItems(page.url, source.pageTree, {
|
||||
includePage: false,
|
||||
includeRoot: true,
|
||||
});
|
||||
for (const page of pages) {
|
||||
if (page.data.type === 'openapi') continue;
|
||||
|
||||
return {
|
||||
id: page.url,
|
||||
structured: structuredData,
|
||||
tag: page.slugs[0],
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
breadcrumbs: items.flatMap<string>((item, i) =>
|
||||
i > 0 && typeof item.name === 'string' ? item.name : [],
|
||||
),
|
||||
} satisfies OramaDocument;
|
||||
const items = getBreadcrumbItems(page.url, source.pageTree, {
|
||||
includePage: false,
|
||||
includeRoot: true,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: page.url,
|
||||
structured: page.data.structuredData,
|
||||
tag: page.slugs[0],
|
||||
url: page.url,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
breadcrumbs: items.flatMap<string>((item, i) =>
|
||||
i > 0 && typeof item.name === 'string' ? item.name : [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json(results);
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import type { InferPageType } from 'fumadocs-core/source';
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
@@ -11,7 +11,18 @@ To begin, make a `getLLMText` function that converts pages into static MDX conte
|
||||
|
||||
In **Fumadocs MDX**, you can do:
|
||||
|
||||
<include meta='title="lib/get-llm-text.ts"'>./get-llm-text.ts</include>
|
||||
```ts title="lib/get-llm-text.ts"
|
||||
import { source } from '@/lib/source';
|
||||
import type { InferPageType } from 'fumadocs-core/source';
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
```
|
||||
|
||||
It requires `includeProcessedMarkdown` to be enabled:
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
type FileObject,
|
||||
printErrors,
|
||||
scanURLs,
|
||||
validateFiles,
|
||||
} from 'next-validate-link';
|
||||
import type { InferPageType } from 'fumadocs-core/source';
|
||||
import { source } from '@/lib/source';
|
||||
|
||||
async function checkLinks() {
|
||||
const scanned = await scanURLs({
|
||||
// pick a preset for your React framework
|
||||
preset: 'next',
|
||||
populate: {
|
||||
'docs/[[...slug]]': source.getPages().map((page) => {
|
||||
return {
|
||||
value: {
|
||||
slug: page.slugs,
|
||||
},
|
||||
hashes: getHeadings(page),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
printErrors(
|
||||
await validateFiles(await getFiles(), {
|
||||
scanned,
|
||||
// check `href` attributes in different MDX components
|
||||
markdown: {
|
||||
components: {
|
||||
Card: { attributes: ['href'] },
|
||||
},
|
||||
},
|
||||
// check relative paths
|
||||
checkRelativePaths: 'as-url',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function getHeadings({ data }: InferPageType<typeof source>): string[] {
|
||||
return data.toc.map((item) => item.url.slice(1));
|
||||
}
|
||||
|
||||
function getFiles() {
|
||||
const promises = source.getPages().map(
|
||||
async (page): Promise<FileObject> => ({
|
||||
path: page.absolutePath,
|
||||
content: await page.data.getText('raw'),
|
||||
url: page.url,
|
||||
data: page.data,
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
void checkLinks();
|
||||
@@ -11,7 +11,67 @@ You can use [`next-validate-link`](https://next-validate-link.vercel.app) to val
|
||||
|
||||
Create a script for Bun:
|
||||
|
||||
<include meta='title="./scripts/lint.ts"'>./validate-links.example.ts</include>
|
||||
```ts title="scripts/lint.ts"
|
||||
import {
|
||||
type FileObject,
|
||||
printErrors,
|
||||
scanURLs,
|
||||
validateFiles,
|
||||
} from 'next-validate-link';
|
||||
import type { InferPageType } from 'fumadocs-core/source';
|
||||
import { source } from '@/lib/source';
|
||||
|
||||
async function checkLinks() {
|
||||
const scanned = await scanURLs({
|
||||
// pick a preset for your React framework
|
||||
preset: 'next',
|
||||
populate: {
|
||||
'docs/[[...slug]]': source.getPages().map((page) => {
|
||||
return {
|
||||
value: {
|
||||
slug: page.slugs,
|
||||
},
|
||||
hashes: getHeadings(page),
|
||||
};
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
printErrors(
|
||||
await validateFiles(await getFiles(), {
|
||||
scanned,
|
||||
// check `href` attributes in different MDX components
|
||||
markdown: {
|
||||
components: {
|
||||
Card: { attributes: ['href'] },
|
||||
},
|
||||
},
|
||||
// check relative paths
|
||||
checkRelativePaths: 'as-url',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function getHeadings({ data }: InferPageType<typeof source>): string[] {
|
||||
return data.toc.map((item) => item.url.slice(1));
|
||||
}
|
||||
|
||||
function getFiles() {
|
||||
const promises = source.getPages().map(
|
||||
async (page): Promise<FileObject> => ({
|
||||
path: page.absolutePath,
|
||||
content: await page.data.getText('raw'),
|
||||
url: page.url,
|
||||
data: page.data,
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
void checkLinks();
|
||||
```
|
||||
|
||||
### Running Lint
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export function buildGraph(): Graph {
|
||||
const graph: Graph = { links: [], nodes: [] };
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.data.type === 'openapi') continue;
|
||||
|
||||
graph.nodes.push({
|
||||
id: page.url,
|
||||
url: page.url,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type Page } from '@/lib/source';
|
||||
|
||||
export async function getLLMText(page: Page) {
|
||||
if (page.data.type === 'openapi') return '';
|
||||
|
||||
const category =
|
||||
{
|
||||
ui: 'Fumadocs Framework',
|
||||
|
||||
+12
-11
@@ -3,24 +3,25 @@ import {
|
||||
type InferMetaType,
|
||||
type InferPageType,
|
||||
loader,
|
||||
multiple,
|
||||
} from 'fumadocs-core/source';
|
||||
import { openapiPlugin } from 'fumadocs-openapi/server';
|
||||
import { openapiPlugin, openapiSource } from 'fumadocs-openapi/server';
|
||||
import { blog as blogPosts, docs } from '@/.source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { openapi } from '@/lib/openapi';
|
||||
import { APIPage } from 'fumadocs-openapi/ui';
|
||||
|
||||
export const source = loader(docs.toFumadocsSource(), {
|
||||
baseUrl: '/docs',
|
||||
plugins: [
|
||||
lucideIconsPlugin(),
|
||||
await openapiPlugin.withPages({
|
||||
from: openapi,
|
||||
APIPage,
|
||||
export const source = loader(
|
||||
multiple({
|
||||
docs: docs.toFumadocsSource(),
|
||||
openapi: await openapiSource(openapi, {
|
||||
baseDir: 'openapi/(generated)',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}),
|
||||
{
|
||||
baseUrl: '/docs',
|
||||
plugins: [lucideIconsPlugin(), openapiPlugin()],
|
||||
},
|
||||
);
|
||||
|
||||
export const blog = loader(createMDXSource(blogPosts), {
|
||||
baseUrl: '/blog',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"clean": "rimraf .next",
|
||||
"dev": "next dev --turbo",
|
||||
"lint": "fumadocs-mdx && bun ./scripts/lint.ts && eslint .",
|
||||
"types:check": "tsc --noEmit",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -50,7 +50,8 @@ async function checkLinks() {
|
||||
);
|
||||
}
|
||||
|
||||
function getHeadings({ data }: InferPageType<AnySource>) {
|
||||
function getHeadings({ data }: InferPageType<AnySource>): string[] {
|
||||
if ('type' in data && data.type === 'openapi') return [];
|
||||
const headings = data.toc.map((item) => item.url.slice(1));
|
||||
const elementIds = data._exports?.elementIds;
|
||||
if (Array.isArray(elementIds)) {
|
||||
@@ -63,6 +64,8 @@ function getHeadings({ data }: InferPageType<AnySource>) {
|
||||
async function getFiles(source: AnySource) {
|
||||
const files: FileObject[] = [];
|
||||
for (const page of source.getPages()) {
|
||||
if ('type' in page.data && page.data.type === 'openapi') continue;
|
||||
|
||||
files.push({
|
||||
data: page.data,
|
||||
url: page.url,
|
||||
|
||||
@@ -6,7 +6,14 @@ import {
|
||||
type MetaFile,
|
||||
type PageFile,
|
||||
} from './storage/content';
|
||||
import type { MetaData, PageData, UrlFn } from './types';
|
||||
import type {
|
||||
MetaData,
|
||||
PageData,
|
||||
UrlFn,
|
||||
VirtualFile,
|
||||
VirtualMeta,
|
||||
VirtualPage,
|
||||
} from './types';
|
||||
import {
|
||||
createPageTreeBuilder,
|
||||
type PageTreeOptions,
|
||||
@@ -90,33 +97,6 @@ interface SharedFileInfo {
|
||||
absolutePath: string;
|
||||
}
|
||||
|
||||
export type VirtualFile<Config extends SourceConfig = SourceConfig> = {
|
||||
/**
|
||||
* Virtualized path (relative to content directory)
|
||||
*
|
||||
* @example `docs/page.mdx`
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* Absolute path of the file
|
||||
*/
|
||||
absolutePath?: string;
|
||||
} & (
|
||||
| {
|
||||
type: 'page';
|
||||
/**
|
||||
* Specified Slugs for page
|
||||
*/
|
||||
slugs?: string[];
|
||||
data: Config['pageData'];
|
||||
}
|
||||
| {
|
||||
type: 'meta';
|
||||
data: Config['metaData'];
|
||||
}
|
||||
);
|
||||
|
||||
export interface Page<Data = PageData> extends SharedFileInfo {
|
||||
slugs: string[];
|
||||
url: string;
|
||||
@@ -263,7 +243,7 @@ export function loader<
|
||||
Config extends SourceConfig,
|
||||
I18n extends I18nConfig | undefined = undefined,
|
||||
>(
|
||||
source: Source<Config> | Source<Config>[],
|
||||
source: Source<Config>,
|
||||
options: LoaderOptions<NoInfer<Config>, I18n>,
|
||||
): LoaderOutput<{
|
||||
source: Config;
|
||||
@@ -275,7 +255,7 @@ export function loader<
|
||||
I18n extends I18nConfig | undefined = undefined,
|
||||
>(
|
||||
options: LoaderOptions<NoInfer<Config>, I18n> & {
|
||||
source: Source<Config> | Source<Config>[];
|
||||
source: Source<Config>;
|
||||
},
|
||||
): LoaderOutput<{
|
||||
source: Config;
|
||||
@@ -286,10 +266,10 @@ export function loader(
|
||||
...args:
|
||||
| [
|
||||
LoaderOptions & {
|
||||
source: Source | Source[];
|
||||
source: Source;
|
||||
},
|
||||
]
|
||||
| [Source | Source[], LoaderOptions]
|
||||
| [Source, LoaderOptions]
|
||||
): LoaderOutput<LoaderConfig> {
|
||||
const resolved =
|
||||
args.length === 2
|
||||
@@ -300,32 +280,17 @@ export function loader(
|
||||
}
|
||||
|
||||
function resolveConfig(
|
||||
source: Source | Source[],
|
||||
source: Source,
|
||||
{ slugs, icon, plugins = [], baseUrl, url, ...base }: LoaderOptions,
|
||||
): ResolvedLoaderConfig {
|
||||
const getUrl: UrlFn = url
|
||||
? (...args) => normalizeUrl(url(...args))
|
||||
: createGetUrl(baseUrl, base.i18n);
|
||||
|
||||
let mergedSource: Source;
|
||||
if (Array.isArray(source)) {
|
||||
mergedSource = { files: [] };
|
||||
for (const item of source) {
|
||||
mergedSource.files.push(
|
||||
// TODO: remove on v16
|
||||
...(typeof item.files === 'function'
|
||||
? (item.files as () => VirtualFile[])()
|
||||
: item.files),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
mergedSource = source;
|
||||
}
|
||||
|
||||
let config: ResolvedLoaderConfig = {
|
||||
...base,
|
||||
url: getUrl,
|
||||
source: mergedSource,
|
||||
source,
|
||||
plugins: buildPlugins([
|
||||
slugsPlugin(slugs),
|
||||
icon && iconPlugin(icon),
|
||||
@@ -520,17 +485,61 @@ function fileToPage<Data = PageData>(
|
||||
};
|
||||
}
|
||||
|
||||
export type _ConfigUnion_<T extends Record<string, Source>> = {
|
||||
[K in keyof T]: T[K] extends Source<infer Config>
|
||||
? {
|
||||
pageData: Config['pageData'] & { type: K };
|
||||
metaData: Config['metaData'] & { type: K };
|
||||
}
|
||||
: never;
|
||||
}[keyof T];
|
||||
|
||||
export function multiple<T extends Record<string, Source>>(sources: T) {
|
||||
const out: Source<_ConfigUnion_<T>> = { files: [] };
|
||||
|
||||
for (const [type, source] of Object.entries(sources)) {
|
||||
for (const file of source.files) {
|
||||
out.files.push({
|
||||
...file,
|
||||
data: {
|
||||
...file.data,
|
||||
type,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* map virtual files in source
|
||||
*/
|
||||
export function map<
|
||||
Config extends SourceConfig,
|
||||
$Config extends SourceConfig = Config,
|
||||
>(
|
||||
source: Source<Config>,
|
||||
fn: (entry: VirtualFile<Config>) => VirtualFile<$Config>,
|
||||
): Source<$Config> {
|
||||
export function map<Config extends SourceConfig>(source: Source<Config>) {
|
||||
return {
|
||||
files: source.files.map(fn),
|
||||
page<$Page extends PageData>(
|
||||
fn: (entry: VirtualPage<Config['pageData']>) => VirtualPage<$Page>,
|
||||
): Source<{
|
||||
pageData: $Page;
|
||||
metaData: Config['metaData'];
|
||||
}> {
|
||||
return {
|
||||
files: source.files.map((file) =>
|
||||
file.type === 'page' ? fn(file) : file,
|
||||
),
|
||||
};
|
||||
},
|
||||
meta<$Meta extends MetaData>(
|
||||
fn: (entry: VirtualMeta<Config['metaData']>) => VirtualMeta<$Meta>,
|
||||
): Source<{
|
||||
pageData: Config['pageData'];
|
||||
metaData: $Meta;
|
||||
}> {
|
||||
return {
|
||||
files: source.files.map((file) =>
|
||||
file.type === 'meta' ? fn(file) : file,
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type MetaData, type PageData } from '@/source/types';
|
||||
import { FileSystem } from './file-system';
|
||||
import { type VirtualFile } from '@/source/loader';
|
||||
import type { MetaData, PageData, VirtualFile } from '@/source/types';
|
||||
import { FileSystem } from '@/source/storage/file-system';
|
||||
import { basename, dirname, joinPath, slash, splitPath } from '@/source/path';
|
||||
import type { I18nConfig } from '@/i18n';
|
||||
import type { LoaderPlugin } from '@/source/plugins';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LoaderOutput, Meta, Page } from './loader';
|
||||
import type { LoaderOutput, Meta, Page, SourceConfig } from './loader';
|
||||
|
||||
export interface MetaData {
|
||||
icon?: string | undefined;
|
||||
@@ -16,6 +16,38 @@ export interface PageData {
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
interface BaseVirtualFile {
|
||||
/**
|
||||
* Virtualized path (relative to content directory)
|
||||
*
|
||||
* @example `docs/page.mdx`
|
||||
*/
|
||||
path: string;
|
||||
|
||||
/**
|
||||
* Absolute path of the file
|
||||
*/
|
||||
absolutePath?: string;
|
||||
}
|
||||
|
||||
export interface VirtualPage<Data extends PageData> extends BaseVirtualFile {
|
||||
type: 'page';
|
||||
/**
|
||||
* Specified Slugs for page
|
||||
*/
|
||||
slugs?: string[];
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export interface VirtualMeta<Data extends MetaData> extends BaseVirtualFile {
|
||||
type: 'meta';
|
||||
data: Data;
|
||||
}
|
||||
|
||||
export type VirtualFile<Config extends SourceConfig = SourceConfig> =
|
||||
| VirtualPage<Config['pageData']>
|
||||
| VirtualMeta<Config['metaData']>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- infer types
|
||||
export type InferPageType<Utils extends LoaderOutput<any>> =
|
||||
Utils extends LoaderOutput<infer Config>
|
||||
|
||||
Reference in New Issue
Block a user