Core: Support multiple() for multiple sources in loader()

This commit is contained in:
Fuma Nama
2025-10-06 22:52:08 +08:00
parent f99350db85
commit 31b9494ab1
17 changed files with 245 additions and 182 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support `multiple()` for multiple sources in same `loader()`
+23 -14
View File
@@ -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>
);
}
+1 -4
View File
@@ -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'));
+2 -1
View File
@@ -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 {
+19 -19
View File
@@ -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
+2
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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": {
+4 -1
View File
@@ -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,
+66 -57
View File
@@ -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,
),
};
},
};
}
+2 -3
View 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';
+33 -1
View File
@@ -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>