Core: Support context-aware type-safe slugs function in loader()

This commit is contained in:
Fuma Nama
2025-12-30 23:02:40 +08:00
parent 7ad806f2f6
commit 98d38fff01
8 changed files with 116 additions and 84 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-core": patch
---
Support context-aware type-safe `slugs` function in `loader()`
+1
View File
@@ -12,5 +12,6 @@
"**/packages/create-app/template/**/*": "plaintext"
},
"prettier.enable": false,
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode"
}
-1
View File
@@ -6,4 +6,3 @@ export * as PathUtils from './path';
export type * from './page-tree/builder';
export type * from './storage/content';
export type * from './plugins';
+56 -4
View File
@@ -4,12 +4,12 @@ import { buildContentStorage, type ContentStorage } from './storage/content';
import { createPageTreeBuilder, type PageTreeOptions } from '@/source/page-tree/builder';
import { joinPath } from './path';
import { normalizeUrl } from '@/utils/normalize-url';
import { buildPlugins, type LoaderPlugin, type LoaderPluginOption } from '@/source/plugins';
import { slugsPlugin } from '@/source/plugins/slugs';
import { SlugFn, slugsPlugin } from '@/source/plugins/slugs';
import { iconPlugin, type IconResolver } from '@/source/plugins/icon';
import type { MetaData, PageData, Source, SourceConfig } from './source';
import { visit } from '@/page-tree/utils';
import path from 'node:path';
import type { PageTreeTransformer } from '@/source/page-tree/builder';
export interface LoaderConfig {
source: SourceConfig;
@@ -32,7 +32,7 @@ export interface LoaderOptions<C extends LoaderConfig = LoaderConfig> {
typedPlugin: (plugin: LoaderPlugin<C>) => LoaderPlugin;
}) => LoaderPluginOption[]);
icon?: IconResolver;
slugs?: (info: { path: string }) => string[];
slugs?: SlugFn<C>;
}
export interface ResolvedLoaderConfig {
@@ -418,13 +418,13 @@ function resolveConfig(
url: url ? (...args) => normalizeUrl(url(...args)) : createGetUrl(baseUrl, base.i18n),
source,
plugins: buildPlugins([
slugsPlugin(slugs),
icon && iconPlugin(icon),
...(typeof plugins === 'function'
? plugins({
typedPlugin: (plugin) => plugin as unknown as LoaderPlugin,
})
: plugins),
slugsPlugin(slugs),
]),
};
@@ -436,6 +436,58 @@ function resolveConfig(
return config;
}
export interface LoaderPlugin<Config extends LoaderConfig = LoaderConfig> {
name?: string;
/**
* Change the order of plugin:
* - `pre`: before normal plugins
* - `post`: after normal plugins
*/
enforce?: 'pre' | 'post';
/**
* receive & replace loader options
*/
config?: (config: ResolvedLoaderConfig) => ResolvedLoaderConfig | void | undefined;
/**
* transform the storage after loading
*/
transformStorage?: (context: { storage: ContentStorage<Config['source']> }) => void;
/**
* transform the generated page tree
*/
transformPageTree?: PageTreeTransformer<Config['source']>;
}
export type LoaderPluginOption<Config extends LoaderConfig = LoaderConfig> =
| LoaderPlugin<Config>
| LoaderPluginOption<Config>[]
| undefined;
const priorityMap = {
pre: 1,
default: 0,
post: -1,
};
function buildPlugins(plugins: LoaderPluginOption[], sort = true): LoaderPlugin[] {
const flatten: LoaderPlugin[] = [];
for (const plugin of plugins) {
if (Array.isArray(plugin)) flatten.push(...buildPlugins(plugin, false));
else if (plugin) flatten.push(plugin);
}
if (sort)
return flatten.sort(
(a, b) => priorityMap[b.enforce ?? 'default'] - priorityMap[a.enforce ?? 'default'],
);
return flatten;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- infer types
export type InferPageType<Utils extends LoaderOutput<any>> =
Utils extends LoaderOutput<infer Config> ? Page<Config['source']['pageData']> : never;
-55
View File
@@ -1,55 +0,0 @@
import type { ContentStorage } from '@/source/storage/content';
import type { PageTreeTransformer } from '@/source/page-tree/builder';
import type { LoaderConfig, ResolvedLoaderConfig } from '@/source/loader';
export interface LoaderPlugin<Config extends LoaderConfig = LoaderConfig> {
name?: string;
/**
* Change the order of plugin:
* - `pre`: before normal plugins
* - `post`: after normal plugins
*/
enforce?: 'pre' | 'post';
/**
* receive & replace loader options
*/
config?: (config: ResolvedLoaderConfig) => ResolvedLoaderConfig | void | undefined;
/**
* transform the storage after loading
*/
transformStorage?: (context: { storage: ContentStorage<Config['source']> }) => void;
/**
* transform the generated page tree
*/
transformPageTree?: PageTreeTransformer<Config['source']>;
}
export type LoaderPluginOption<Config extends LoaderConfig = LoaderConfig> =
| LoaderPlugin<Config>
| LoaderPluginOption<Config>[]
| undefined;
const priorityMap = {
pre: 1,
default: 0,
post: -1,
};
export function buildPlugins(plugins: LoaderPluginOption[], sort = true): LoaderPlugin[] {
const flatten: LoaderPlugin[] = [];
for (const plugin of plugins) {
if (Array.isArray(plugin)) flatten.push(...buildPlugins(plugin, false));
else if (plugin) flatten.push(plugin);
}
if (sort)
return flatten.sort(
(a, b) => priorityMap[b.enforce ?? 'default'] - priorityMap[a.enforce ?? 'default'],
);
return flatten;
}
+36 -10
View File
@@ -1,10 +1,20 @@
import type { LoaderPlugin } from '@/source/plugins';
import { basename, dirname, extname } from '@/source/path';
import type { ContentStoragePageFile } from '../storage/content';
import type { LoaderConfig, LoaderPlugin } from '../loader';
/**
* a function to generate slugs, return `undefined` to fallback to default generation.
*/
export type SlugFn<Config extends LoaderConfig = LoaderConfig> = (
file: ContentStoragePageFile<Config['source']>,
) => string[] | undefined;
/**
* Generate slugs for pages if missing
*/
export function slugsPlugin(slugsFn?: (info: { path: string }) => string[]): LoaderPlugin {
export function slugsPlugin<Config extends LoaderConfig = LoaderConfig>(
slugFn?: SlugFn<Config>,
): LoaderPlugin<Config> {
function isIndex(file: string) {
return basename(file, extname(file)) === 'index';
}
@@ -12,24 +22,23 @@ export function slugsPlugin(slugsFn?: (info: { path: string }) => string[]): Loa
return {
name: 'fumadocs:slugs',
transformStorage({ storage }) {
const indexFiles = new Set<string>();
const indexFiles: string[] = [];
const taken = new Set<string>();
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
const autoIndex = slugsFn === undefined;
for (const path of storage.getFiles()) {
const file = storage.read(path);
if (!file || file.format !== 'page' || file.slugs) continue;
if (isIndex(path) && autoIndex) {
indexFiles.add(path);
const customSlugs = slugFn?.(file);
// for custom slugs function, don't handle conflicting cases like `dir/index.mdx` vs `dir.mdx`
if (customSlugs === undefined && isIndex(path)) {
indexFiles.push(path);
continue;
}
file.slugs = slugsFn ? slugsFn({ path }) : getSlugs(path);
file.slugs = customSlugs ?? getSlugs(path);
const key = file.slugs.join('/');
if (taken.has(key)) throw new Error('Duplicated slugs');
if (taken.has(key)) throw new Error(`Duplicated slugs: ${key}`);
taken.add(key);
}
@@ -44,6 +53,23 @@ export function slugsPlugin(slugsFn?: (info: { path: string }) => string[]): Loa
};
}
/**
* Generate slugs from file data (e.g. frontmatter).
*
* @param key - the property name in file data to generate slugs, default to `slug`.
*/
export function slugsFromData<Config extends LoaderConfig = LoaderConfig>(
key = 'slug',
): SlugFn<Config> {
return (file) => {
const k = key as keyof typeof file.data;
if (k in file.data && typeof file.data[k] === 'string') {
return file.data[k].split('/').filter((v) => v.length > 0);
}
};
}
const GroupRegex = /^\(.+\)$/;
/**
+17 -13
View File
@@ -8,21 +8,25 @@ export type ContentStorage<Config extends SourceConfig = SourceConfig> = FileSys
>;
export type ContentStorageFile<Config extends SourceConfig = SourceConfig> =
| {
path: string;
absolutePath?: string;
| ContentStorageMetaFile<Config>
| ContentStoragePageFile<Config>;
format: 'meta';
data: Config['metaData'];
}
| {
path: string;
absolutePath?: string;
export interface ContentStorageMetaFile<Config extends SourceConfig = SourceConfig> {
path: string;
absolutePath?: string;
format: 'page';
slugs: string[];
data: Config['pageData'];
};
format: 'meta';
data: Config['metaData'];
}
export interface ContentStoragePageFile<Config extends SourceConfig = SourceConfig> {
path: string;
absolutePath?: string;
format: 'page';
slugs: string[];
data: Config['pageData'];
}
function isLocaleValid(locale: string) {
return locale.length > 0 && !/\d+/.test(locale);
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
'src/content/mdx/preset-*.ts',
'src/source/{index,schema}.ts',
'src/source/client/*.{ts,tsx}',
'src/source/plugins/lucide-icons.ts',
'src/source/plugins/{lucide-icons,slugs}.ts',
'src/search/{index,client,server,algolia,orama-cloud}.ts',
'src/utils/use-on-change.ts',
'src/utils/use-effect-event.ts',