Chore: Support custom base directory for content sources

This commit is contained in:
Fuma Nama
2026-05-15 18:06:59 +08:00
parent 99f1066de5
commit 1fb6a6168c
7 changed files with 92 additions and 57 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@fumadocs/local-md': patch
'@fumadocs/sanity': patch
'fumadocs-mdx': patch
---
Support custom base directory for content sources
+26 -11
View File
@@ -40,18 +40,19 @@ export function splitPath(path: string): string[] {
*/
export function joinPath(...paths: string[]): string {
const out = [];
const parsed = paths.flatMap((path) => path.split('/'));
for (const seg of parsed) {
switch (seg) {
case '..':
out.pop();
break;
case '':
case '.':
break;
default:
out.push(seg);
for (const path of paths) {
for (const seg of path.split('/')) {
switch (seg) {
case '..':
out.pop();
break;
case '':
case '.':
break;
default:
out.push(seg);
}
}
}
@@ -67,3 +68,17 @@ export function slash(path: string): string {
return path.replaceAll('\\', '/');
}
/**
* Convert (relative) file path to virtual file path.
*
* @param path - Relative path
* @returns Normalized path, with no trailing/leading slashes
* @throws Throws error if path starts with `./` or `../`
*/
export function normalize(path: string): string {
const segments = path.split(/\/|\\/).filter((v) => v.length > 0);
if (segments[0] === '.' || segments[0] === '..')
throw new Error("It must not start with './' or '../'");
return segments.join('/');
}
+3 -15
View File
@@ -1,5 +1,5 @@
import { FileSystem } from '@/source/storage/file-system';
import { slash, splitPath } from '@/source/path';
import { normalize } from '@/source/path';
import type { ResolvedLoaderConfig } from '../loader';
import { isStaticSource, MetaData, PageData, type StaticSource } from '../source';
@@ -99,7 +99,7 @@ export function createContentStorageBuilder(loaderConfig: ResolvedLoaderConfig)
file = {
format: 'page',
type,
path: normalizePath(inputFile.path),
path: normalize(inputFile.path),
// will be generated by the slugs plugin if unspecified
slugs: inputFile.slugs as string[],
data: inputFile.data,
@@ -109,7 +109,7 @@ export function createContentStorageBuilder(loaderConfig: ResolvedLoaderConfig)
file = {
format: 'meta',
type,
path: normalizePath(inputFile.path),
path: normalize(inputFile.path),
absolutePath: inputFile.absolutePath,
data: inputFile.data,
};
@@ -171,18 +171,6 @@ export function createContentStorageBuilder(loaderConfig: ResolvedLoaderConfig)
};
}
/**
* @param path - Relative path
* @returns Normalized path, with no trailing/leading slashes
* @throws Throws error if path starts with `./` or `../`
*/
function normalizePath(path: string): string {
const segments = splitPath(slash(path));
if (segments[0] === '.' || segments[0] === '..')
throw new Error("It must not start with './' or '../'");
return segments.join('/');
}
function pushMapList<K, V>(map: Map<K, V[]>, k: K, v: V) {
let list = map.get(k);
if (!list) {
+25 -15
View File
@@ -1,9 +1,9 @@
import type {
DynamicSource,
MetaData,
PageData,
StaticSource,
VirtualFile,
import {
type DynamicSource,
type MetaData,
type PageData,
type StaticSource,
type VirtualFile,
} from 'fumadocs-core/source';
import type { StandardSchemaV1 } from '@standard-schema/spec';
import path from 'node:path';
@@ -45,13 +45,17 @@ export interface LocalMarkdown<
> {
/** connect to dev server, required for hot reload */
devServer: (url?: string) => Promise<void>;
staticSource: <ModuleExports = Record<string, unknown>>() => Promise<
staticSource: <ModuleExports = Record<string, unknown>>(
options?: SourceOptions,
) => Promise<
StaticSource<{
pageData: LocalMarkdownPage<StandardSchemaV1.InferOutput<FrontmatterSchema>, ModuleExports>;
metaData: StandardSchemaV1.InferOutput<MetaSchema> & MetaData;
}>
>;
dynamicSource: <ModuleExports = Record<string, unknown>>() => DynamicSource<{
dynamicSource: <ModuleExports = Record<string, unknown>>(
options?: SourceOptions,
) => DynamicSource<{
pageData: LocalMarkdownPage<StandardSchemaV1.InferOutput<FrontmatterSchema>, ModuleExports>;
metaData: StandardSchemaV1.InferOutput<MetaSchema> & MetaData;
}>;
@@ -72,6 +76,11 @@ export interface LocalMarkdownPage<
load: () => Promise<MarkdownRenderer<ModuleExports>>;
}
interface SourceOptions {
/** base directory for virtual file paths */
baseDir?: string;
}
export function localMd<
FrontmatterSchema extends StandardSchemaV1 = typeof defaultSchemas.pageSchema,
MetaSchema extends StandardSchemaV1 = typeof defaultSchemas.metaSchema,
@@ -89,8 +98,9 @@ export function localMd<
}>
> | null = null;
async function createFiles() {
async function createFiles(options?: SourceOptions) {
const { metas, pages } = await storage.getPages();
const baseDir = options?.baseDir;
const files: VirtualFile<{
pageData: LocalMarkdownPage<StandardSchemaV1.InferOutput<FrontmatterSchema>, never>;
metaData: StandardSchemaV1.InferOutput<MetaSchema> & MetaData;
@@ -101,7 +111,7 @@ export function localMd<
files.push({
type: 'page',
path: page.path,
path: baseDir ? path.join(baseDir, page.path) : page.path,
absolutePath: page.absolutePath,
data: {
title: frontmatter.title ?? path.basename(page.path, path.extname(page.path)),
@@ -145,7 +155,7 @@ export function localMd<
for (const meta of metas) {
files.push({
type: 'meta',
path: meta.path,
path: baseDir ? path.join(baseDir, meta.path) : meta.path,
absolutePath: meta.absolutePath,
data: meta.data!,
});
@@ -183,16 +193,16 @@ export function localMd<
}
});
},
dynamicSource() {
dynamicSource(opts) {
return {
files: createFiles,
files: () => createFiles(opts),
configure(loader) {
registeredLoaders.add(loader);
},
};
},
staticSource() {
return (cachedStaticSource ??= createFiles().then((files) => ({ files })));
staticSource(opts) {
return (cachedStaticSource ??= createFiles(opts).then((files) => ({ files })));
},
};
}
+16 -9
View File
@@ -1,4 +1,4 @@
import type { MetaData, PageData, Source, VirtualFile } from 'fumadocs-core/source';
import { type MetaData, type PageData, type Source, type VirtualFile } from 'fumadocs-core/source';
import * as path from 'node:path';
import type { DocCollection, DocsCollection, MetaCollection } from '@/config';
import type { StandardSchemaV1 } from '@standard-schema/spec';
@@ -13,6 +13,11 @@ export type DocCollectionEntry<
TC extends InternalTypeConfig = InternalTypeConfig,
> = DocData & DocMethods & Frontmatter & TC['DocData'][Name];
interface ToFumadocsSourceOptions {
/** base directory for virtual file paths */
baseDir?: string;
}
export type AsyncDocCollectionEntry<
Name extends string = string,
Frontmatter = unknown,
@@ -30,7 +35,7 @@ export interface DocsCollectionEntry<
> {
docs: DocCollectionEntry<Name, Frontmatter, TC>[];
meta: MetaCollectionEntry<Meta>[];
toFumadocsSource: () => Source<{
toFumadocsSource: (options?: ToFumadocsSourceOptions) => Source<{
pageData: DocCollectionEntry<Name, Frontmatter, TC>;
metaData: MetaCollectionEntry<Meta>;
}>;
@@ -44,7 +49,7 @@ export interface AsyncDocsCollectionEntry<
> {
docs: AsyncDocCollectionEntry<Name, Frontmatter, TC>[];
meta: MetaCollectionEntry<Meta>[];
toFumadocsSource: () => Source<{
toFumadocsSource: (options?: ToFumadocsSourceOptions) => Source<{
pageData: AsyncDocCollectionEntry<Name, Frontmatter, TC>;
metaData: MetaCollectionEntry<Meta>;
}>;
@@ -175,8 +180,8 @@ export function server<Config, TC extends InternalTypeConfig>(options: ServerOpt
const entry = {
docs: await this.doc(name, base, docGlob),
meta: await this.meta(name, base, metaGlob),
toFumadocsSource() {
return toFumadocsSource(this.docs, this.meta);
toFumadocsSource(options) {
return toFumadocsSource(this.docs, this.meta, options);
},
} satisfies DocsCollectionEntry;
@@ -203,8 +208,8 @@ export function server<Config, TC extends InternalTypeConfig>(options: ServerOpt
const entry = {
docs: await this.docLazy(name, base, docHeadGlob, docBodyGlob),
meta: await this.meta(name, base, metaGlob),
toFumadocsSource() {
return toFumadocsSource(this.docs, this.meta);
toFumadocsSource(options) {
return toFumadocsSource(this.docs, this.meta, options);
},
} satisfies AsyncDocsCollectionEntry;
@@ -230,10 +235,12 @@ export function toFumadocsSource<
>(
pages: Page[],
metas: Meta[],
options?: ToFumadocsSourceOptions,
): Source<{
pageData: Page;
metaData: Meta;
}> {
const baseDir = options?.baseDir;
const files: VirtualFile<{
pageData: Page;
metaData: Meta;
@@ -242,7 +249,7 @@ export function toFumadocsSource<
for (const entry of pages) {
files.push({
type: 'page',
path: entry.info.path,
path: baseDir ? path.join(baseDir, entry.info.path) : entry.info.path,
absolutePath: entry.info.fullPath,
data: entry,
});
@@ -251,7 +258,7 @@ export function toFumadocsSource<
for (const entry of metas) {
files.push({
type: 'meta',
path: entry.info.path,
path: baseDir ? path.join(baseDir, entry.info.path) : entry.info.path,
absolutePath: entry.info.fullPath,
data: entry,
});
+3 -1
View File
@@ -15,7 +15,9 @@ export default defineConfig({
},
target: 'es2023',
platform: 'neutral',
exports: true,
exports: {
bin: false,
},
deps: {
onlyBundle: ['@fumadocs/vite'],
neverBundle: ['webpack', 'bun', /^node:/],
+12 -6
View File
@@ -6,6 +6,7 @@ import type { ReactNode } from 'react';
import type { PortableTextBlock, SlugValue } from 'sanity';
import type { QueryParams, SanityClient } from '@sanity/client';
import { renderToc } from './client';
import path from 'node:path';
export type SanityOptions<Doc extends BaseDoc = BaseDoc> =
| GenericSanityOptions<Doc>
@@ -17,6 +18,9 @@ interface BaseSanityOptions<Doc extends BaseDoc> {
/** generate [virtual file path](https://fumadocs.dev/docs/headless/source-api/source#static-source) from document */
generatePath?: (doc: ShallowDoc<Doc>) => string;
/** base directory for the virutal file paths */
baseDir?: string;
}
export interface GenericSanityOptions<
@@ -61,7 +65,7 @@ export function createSanitySource<Doc extends BaseDoc>(
pageData: DocToPage<Doc>;
metaData: MetaData;
}> {
const { docType, generatePath } = options;
const { docType, baseDir, generatePath } = options;
let sanityFetch: <R = unknown>(query: string, params?: QueryParams) => Promise<R>;
if ('sanityFetch' in options) {
@@ -86,6 +90,12 @@ export function createSanitySource<Doc extends BaseDoc>(
return data.map((file) => {
const slugs = file.slug?.current?.split('/').filter((v) => v.length > 0) ?? [];
let filePath = generatePath
? generatePath(file)
: slugs.length === 0
? 'index.mdx'
: `${slugs.join('/')}.mdx`;
if (baseDir) filePath = path.join(baseDir, filePath);
return {
type: 'page',
@@ -142,11 +152,7 @@ export function createSanitySource<Doc extends BaseDoc>(
},
},
slugs,
path: generatePath
? generatePath(file)
: slugs.length === 0
? 'index.mdx'
: `${slugs.join('/')}.mdx`,
path: filePath,
};
});
},