Core: refactor internal type parameters

This commit is contained in:
Fuma Nama
2025-11-21 20:34:52 +08:00
parent 88181b17ce
commit 42ad84c247
9 changed files with 269 additions and 306 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'fumadocs-core': minor
---
**[Loader API] Refactor internal type parameters**
Internal types like `ContentStorage`, `PageTreeTransformer` now use a single `Config extends SourceConfig` generic parameter.
It makes extending their parameters easier, this should not affect normal usages.
+4 -14
View File
@@ -1,19 +1,9 @@
export {
createPageTreeBuilder,
type PageTreeTransformer,
type PageTreeBuilder,
type PageTreeBuilderContext,
type PageTreeOptions,
} from './page-tree/builder';
export * from './source';
export * from './loader';
export { getSlugs } from './plugins/slugs';
export { FileSystem } from './storage/file-system';
export {
type PageFile,
type MetaFile,
type ContentStorage,
} from './storage/content';
export type * from './types';
export * as PathUtils from './path';
export type * from './page-tree/builder';
export type * from './storage/content';
export type * from './plugins';
+47 -154
View File
@@ -1,19 +1,6 @@
import type * as PageTree from '@/page-tree/definitions';
import type { I18nConfig } from '@/i18n';
import {
buildContentStorage,
type ContentStorage,
type MetaFile,
type PageFile,
} from './storage/content';
import type {
MetaData,
PageData,
UrlFn,
VirtualFile,
VirtualMeta,
VirtualPage,
} from './types';
import { buildContentStorage, type ContentStorage } from './storage/content';
import {
createPageTreeBuilder,
type PageTreeOptions,
@@ -27,6 +14,7 @@ import {
} from '@/source/plugins';
import { slugsPlugin } from '@/source/plugins/slugs';
import { iconPlugin, type IconResolver } from '@/source/plugins/icon';
import type { Source, SourceConfig, PageData, MetaData } from './source';
export interface LoaderConfig {
source: SourceConfig;
@@ -36,7 +24,7 @@ export interface LoaderConfig {
export interface LoaderOptions<C extends LoaderConfig = LoaderConfig> {
baseUrl: string;
i18n?: C['i18n'];
url?: UrlFn;
url?: (slugs: string[], locale?: string) => string;
/**
* Additional options for page tree builder
@@ -54,22 +42,13 @@ export interface LoaderOptions<C extends LoaderConfig = LoaderConfig> {
export interface ResolvedLoaderConfig {
source: Source;
url: UrlFn;
url: (slugs: string[], locale?: string) => string;
plugins?: LoaderPlugin[];
pageTree?: PageTreeOptions;
i18n?: I18nConfig | undefined;
}
export interface Source<Config extends SourceConfig = SourceConfig> {
files: VirtualFile<Config>[];
}
export interface SourceConfig {
pageData: PageData;
metaData: MetaData;
}
interface SharedFileInfo {
/**
* Virtualized file path (relative to content directory)
@@ -79,9 +58,9 @@ interface SharedFileInfo {
path: string;
/**
* Absolute path of the file (can be empty)
* Absolute path of the file
*/
absolutePath: string;
absolutePath?: string;
}
export interface Page<Data = PageData> extends SharedFileInfo {
@@ -174,7 +153,10 @@ export interface LoaderOutput<Config extends LoaderConfig> {
) => (Record<TSlug, string[]> & Record<TLang, string>)[];
}
function indexPages(storages: Record<string, ContentStorage>, getUrl: UrlFn) {
function indexPages(
storages: Record<string, ContentStorage>,
{ url }: ResolvedLoaderConfig,
) {
const result = {
// (locale.slugs -> page)
pages: new Map<string, Page>(),
@@ -190,11 +172,22 @@ function indexPages(storages: Record<string, ContentStorage>, getUrl: UrlFn) {
const path = `${lang}.${filePath}`;
if (item.format === 'meta') {
result.pathToMeta.set(path, fileToMeta(item));
result.pathToMeta.set(path, {
path: item.path,
absolutePath: item.absolutePath,
data: item.data,
});
continue;
}
const page = fileToPage(item, getUrl, lang);
const page: Page = {
absolutePath: item.absolutePath,
path: item.path,
url: url(item.slugs, lang),
slugs: item.slugs,
data: item.data,
locale: lang,
};
result.pathToPage.set(path, page);
result.pages.set(`${lang}.${page.slugs.join('/')}`, page);
}
@@ -203,7 +196,10 @@ function indexPages(storages: Record<string, ContentStorage>, getUrl: UrlFn) {
return result;
}
export function createGetUrl(baseUrl: string, i18n?: I18nConfig): UrlFn {
export function createGetUrl(
baseUrl: string,
i18n?: I18nConfig,
): ResolvedLoaderConfig['url'] {
const baseSlugs = baseUrl.split('/');
return (slugs, locale) => {
@@ -276,13 +272,11 @@ function resolveConfig(
source: Source,
{ slugs, icon, plugins = [], baseUrl, url, ...base }: LoaderOptions,
): ResolvedLoaderConfig {
const getUrl: UrlFn = url
? (...args) => normalizeUrl(url(...args))
: createGetUrl(baseUrl, base.i18n);
let config: ResolvedLoaderConfig = {
...base,
url: getUrl,
url: url
? (...args) => normalizeUrl(url(...args))
: createGetUrl(baseUrl, base.i18n),
source,
plugins: buildPlugins([
slugsPlugin(slugs),
@@ -303,51 +297,20 @@ function resolveConfig(
return config;
}
function createOutput({
source: { files },
url: getUrl,
i18n,
plugins = [],
pageTree: pageTreeConfig,
}: ResolvedLoaderConfig): LoaderOutput<LoaderConfig> {
function createOutput(
loaderConfig: ResolvedLoaderConfig,
): LoaderOutput<LoaderConfig> {
const { i18n } = loaderConfig;
const defaultLanguage = i18n?.defaultLanguage ?? '';
const storages = buildContentStorage(
files,
(file) => {
if (file.type === 'page') {
return {
format: 'page',
path: file.path,
slugs: file.slugs,
data: file.data,
absolutePath: file.absolutePath ?? '',
} as PageFile;
}
return {
format: 'meta',
path: file.path,
absolutePath: file.absolutePath ?? '',
data: file.data,
} as MetaFile;
},
plugins,
i18n ?? {
defaultLanguage,
parser: 'none',
languages: [defaultLanguage],
},
);
const walker = indexPages(storages, getUrl);
const builder = createPageTreeBuilder(getUrl, plugins);
const storages = buildContentStorage(loaderConfig, defaultLanguage);
const walker = indexPages(storages, loaderConfig);
const builder = createPageTreeBuilder(loaderConfig);
let pageTree: Record<string, PageTree.Root> | undefined;
return {
_i18n: i18n,
get pageTree() {
pageTree ??= builder.buildI18n(storages, pageTreeConfig);
pageTree ??= builder.buildI18n(storages);
return i18n
? (pageTree as unknown as LoaderOutput<LoaderConfig>['pageTree'])
@@ -452,84 +415,14 @@ function createOutput({
};
}
function fileToMeta<Data = MetaData>(file: MetaFile): Meta<Data> {
return {
path: file.path,
absolutePath: file.absolutePath,
data: file.data as Data,
};
}
function fileToPage<Data = PageData>(
file: PageFile,
getUrl: UrlFn,
locale?: string,
): Page<Data> {
return {
absolutePath: file.absolutePath,
path: file.path,
url: getUrl(file.slugs, locale),
slugs: file.slugs,
data: file.data as Data,
locale,
};
}
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 };
}
// 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;
}[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>(source: Source<Config>) {
return {
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,
),
};
},
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- infer types
export type InferMetaType<Utils extends LoaderOutput<any>> =
Utils extends LoaderOutput<infer Config>
? Meta<Config['source']['metaData']>
: never;
+20 -24
View File
@@ -1,48 +1,46 @@
import type * as PageTree from '@/page-tree/definitions';
import type { LoaderConfig, MetaData, PageData, UrlFn } from '@/source';
import type { LoaderConfig, ResolvedLoaderConfig } from '@/source/loader';
import type { ContentStorage } from '@/source/storage/content';
import { basename, extname, joinPath } from '@/source/path';
import { transformerFallback } from '@/source/page-tree/transformer-fallback';
import type { LoaderPlugin } from '@/source/plugins';
import type { SourceConfig } from '../source';
export interface PageTreeBuilderContext<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
Config extends SourceConfig = SourceConfig,
> {
rootId: string;
generateNodeId: () => string;
options: PageTreeOptions;
transformers: PageTreeTransformer<Page, Meta>[];
transformers: PageTreeTransformer<Config>[];
builder: PageTreeBuilder;
storage: ContentStorage<Page, Meta>;
getUrl: UrlFn;
storage: ContentStorage<Config>;
getUrl: ResolvedLoaderConfig['url'];
storages?: Record<string, ContentStorage<Page, Meta>>;
storages?: Record<string, ContentStorage<Config>>;
locale?: string;
}
export interface PageTreeTransformer<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
Config extends SourceConfig = SourceConfig,
> {
file?: (
this: PageTreeBuilderContext<Page, Meta>,
this: PageTreeBuilderContext<Config>,
node: PageTree.Item,
filePath?: string,
) => PageTree.Item;
folder?: (
this: PageTreeBuilderContext<Page, Meta>,
this: PageTreeBuilderContext<Config>,
node: PageTree.Folder,
folderPath: string,
metaPath?: string,
) => PageTree.Folder;
separator?: (
this: PageTreeBuilderContext<Page, Meta>,
this: PageTreeBuilderContext<Config>,
node: PageTree.Separator,
) => PageTree.Separator;
root?: (
this: PageTreeBuilderContext<Page, Meta>,
this: PageTreeBuilderContext<Config>,
node: PageTree.Root,
) => PageTree.Root;
}
@@ -65,10 +63,7 @@ export interface PageTreeOptions<Config extends LoaderConfig = LoaderConfig> {
/**
* Additional page tree transformers to apply
*/
transformers?: PageTreeTransformer<
Config['source']['pageData'],
Config['source']['metaData']
>[];
transformers?: PageTreeTransformer<Config['source']>[];
}
export interface PageTreeBuilder {
@@ -90,15 +85,16 @@ const extractPrefix = '...';
const excludePrefix = '!';
export function createPageTreeBuilder(
getUrl: UrlFn,
plugins?: LoaderPlugin[],
loaderConfig: ResolvedLoaderConfig,
): PageTreeBuilder {
const { plugins = [], url, pageTree: defaultOptions = {} } = loaderConfig;
return {
build(storage, options) {
build(storage, options = defaultOptions) {
const key = '';
return this.buildI18n({ [key]: storage }, options)[key];
},
buildI18n(storages, options = {}) {
buildI18n(storages, options = defaultOptions) {
let nextId = 0;
const out: Record<string, PageTree.Root> = {};
const transformers: PageTreeTransformer[] = [];
@@ -107,7 +103,7 @@ export function createPageTreeBuilder(
transformers.push(...options.transformers);
}
for (const plugin of plugins ?? []) {
for (const plugin of plugins) {
if (plugin.transformPageTree)
transformers.push(plugin.transformPageTree);
}
@@ -125,7 +121,7 @@ export function createPageTreeBuilder(
transformers,
builder: this,
options,
getUrl,
getUrl: url,
locale,
storage,
storages,
+2 -8
View File
@@ -23,19 +23,13 @@ export interface LoaderPlugin<Config extends LoaderConfig = LoaderConfig> {
* transform the storage after loading
*/
transformStorage?: (context: {
storage: ContentStorage<
Config['source']['pageData'],
Config['source']['metaData']
>;
storage: ContentStorage<Config['source']>;
}) => void;
/**
* transform the generated page tree
*/
transformPageTree?: PageTreeTransformer<
Config['source']['pageData'],
Config['source']['metaData']
>;
transformPageTree?: PageTreeTransformer<Config['source']>;
}
export type LoaderPluginOption<Config extends LoaderConfig = LoaderConfig> =
+115
View File
@@ -0,0 +1,115 @@
export interface Source<Config extends SourceConfig = SourceConfig> {
files: VirtualFile<Config>[];
}
export interface SourceConfig {
pageData: PageData;
metaData: MetaData;
}
export interface MetaData {
icon?: string | undefined;
title?: string | undefined;
root?: boolean | undefined;
pages?: string[] | undefined;
defaultOpen?: boolean | undefined;
description?: string | undefined;
}
export interface PageData {
icon?: string | undefined;
title?: string;
description?: string | undefined;
}
export type VirtualFile<Config extends SourceConfig = SourceConfig> =
| VirtualPage<Config['pageData']>
| VirtualMeta<Config['metaData']>;
interface BaseVirtualFile {
/**
* Virtualized path (relative to content directory)
*
* @example `docs/page.mdx`
*/
path: string;
/**
* Absolute path of the file
*/
absolutePath?: string;
}
interface VirtualPage<Data extends PageData> extends BaseVirtualFile {
type: 'page';
/**
* Specified Slugs for page
*/
slugs?: string[];
data: Data;
}
interface VirtualMeta<Data extends MetaData> extends BaseVirtualFile {
type: 'meta';
data: Data;
}
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>(source: Source<Config>) {
return {
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,
),
};
},
};
}
+72 -36
View File
@@ -1,30 +1,27 @@
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';
import type { ResolvedLoaderConfig } from '../loader';
import type { SourceConfig } from '../source';
export type ContentStorage<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> = FileSystem<MetaFile<Meta> | PageFile<Page>>;
export type ContentStorage<Config extends SourceConfig = SourceConfig> =
FileSystem<ContentStorageFile<Config>>;
export interface MetaFile<Data extends MetaData = MetaData> {
path: string;
absolutePath: string;
export type ContentStorageFile<Config extends SourceConfig = SourceConfig> =
| {
path: string;
absolutePath?: string;
format: 'meta';
data: Data;
}
format: 'meta';
data: Config['metaData'];
}
| {
path: string;
absolutePath?: string;
export interface PageFile<Data extends PageData = PageData> {
path: string;
absolutePath: string;
format: 'page';
slugs: string[];
data: Data;
}
format: 'page';
slugs: string[];
data: Config['pageData'];
};
function isLocaleValid(locale: string) {
return locale.length > 0 && !/\d+/.test(locale);
@@ -56,24 +53,66 @@ const parsers = {
};
/**
* @param defaultLanguage - language to use when i18n is not configured.
* @returns a map of locale and its content storage.
*
* in the storage, locale codes are removed from file paths, hence the same file will have same file paths in every storage.
*/
export function buildContentStorage(
files: VirtualFile[],
buildFile: (file: VirtualFile) => MetaFile | PageFile,
plugins: LoaderPlugin[],
i18n: I18nConfig,
loaderConfig: ResolvedLoaderConfig,
defaultLanguage: string,
): Record<string, ContentStorage> {
const {
source,
plugins = [],
i18n = {
defaultLanguage,
parser: 'none',
languages: [defaultLanguage],
},
} = loaderConfig;
const parser = parsers[i18n.parser ?? 'dot'];
const storages: Record<string, ContentStorage> = {};
const normalized = files.map((file) =>
buildFile({
...file,
path: normalizePath(file.path),
}),
);
const normalized = new Map<
string,
{
pathWithoutLocale: string;
file: ContentStorageFile;
}[]
>();
for (const inputFile of source.files) {
let file: ContentStorageFile;
if (inputFile.type === 'page') {
file = {
format: 'page',
path: normalizePath(inputFile.path),
// will be generated by the slugs plugin if unspecified
slugs: inputFile.slugs as string[],
data: inputFile.data,
absolutePath: inputFile.absolutePath,
};
} else {
file = {
format: 'meta',
path: normalizePath(inputFile.path),
absolutePath: inputFile.absolutePath,
data: inputFile.data,
};
}
const [pathWithoutLocale, locale = i18n.defaultLanguage] = parser(
file.path,
);
const list = normalized.get(locale) ?? [];
list.push({
pathWithoutLocale,
file,
});
normalized.set(locale, list);
}
const fallbackLang =
i18n.fallbackLanguage !== null
? (i18n.fallbackLanguage ?? i18n.defaultLanguage)
@@ -90,16 +129,13 @@ export function buildContentStorage(
storage = new FileSystem();
}
for (const item of normalized) {
const [path, locale = i18n.defaultLanguage] = parser(item.path);
if (locale === lang) storage.write(path, item);
for (const { pathWithoutLocale, file } of normalized.get(lang) ?? []) {
storage.write(pathWithoutLocale, file);
}
const context = {
storage,
};
for (const plugin of plugins) {
plugin.transformStorage?.(context);
}
-66
View File
@@ -1,66 +0,0 @@
import type { LoaderOutput, Meta, Page, SourceConfig } from './loader';
export interface MetaData {
icon?: string | undefined;
title?: string | undefined;
root?: boolean | undefined;
pages?: string[] | undefined;
defaultOpen?: boolean | undefined;
description?: string | undefined;
}
export interface PageData {
icon?: string | undefined;
title?: string;
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>
? Page<Config['source']['pageData']>
: never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- infer types
export type InferMetaType<Utils extends LoaderOutput<any>> =
Utils extends LoaderOutput<infer Config>
? Meta<Config['source']['metaData']>
: never;
/**
* @internal
*/
export type UrlFn = (slugs: string[], locale?: string) => string;
@@ -3,7 +3,6 @@
"language": "cn",
"pages": [
{
"absolutePath": "",
"data": {
"title": "Hello Chinese",
},
@@ -15,7 +14,6 @@
"url": "/cn/test",
},
{
"absolutePath": "",
"data": {
"title": "Nested Page Chinese",
},
@@ -33,7 +31,6 @@
"language": "en",
"pages": [
{
"absolutePath": "",
"data": {
"title": "Hello",
},
@@ -45,7 +42,6 @@
"url": "/en/test",
},
{
"absolutePath": "",
"data": {
"title": "Nested Page",
},