Core & MDX & OpenAPI: Add absolutePath and path properties to pages, mark file as deprecated

This commit is contained in:
Fuma Nama
2025-06-13 19:43:07 +08:00
parent c3d6f9b5ef
commit 66473246dd
15 changed files with 388 additions and 494 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': patch
'fumadocs-core': patch
'fumadocs-mdx': patch
---
Add `absolutePath` and `path` properties to pages, mark `file` as deprecated
+6 -3
View File
@@ -44,6 +44,7 @@ import { Card, Cards } from 'fumadocs-ui/components/card';
import { getMDXComponents } from '@/mdx-components';
import { APIPage } from 'fumadocs-openapi/ui';
import { GitHubLink, LLMCopyButton } from './page.client';
import * as path from 'node:path';
function PreviewRenderer({ preview }: { preview: string }): ReactNode {
if (preview && preview in Preview) {
@@ -66,7 +67,6 @@ export default async function Page(props: {
if (!page) notFound();
const path = `apps/docs/content/docs/${page.file.path}`;
const preview = page.data.preview;
const { body: Mdx, toc, lastModified } = await page.data.load();
@@ -94,7 +94,10 @@ export default async function Page(props: {
<div className="flex flex-row gap-2 items-center mb-8 border-b pb-6">
<LLMCopyButton slug={params.slug} />
<GitHubLink
url={`https://github.com/${owner}/${repo}/blob/dev/${path}`}
url={`https://github.com/${owner}/${repo}/blob/dev/${path.relative(
process.cwd(),
page.absolutePath,
)}`}
/>
</div>
<div className="prose flex-1 text-fd-foreground/80">
@@ -104,7 +107,7 @@ export default async function Page(props: {
...Twoslash,
a: ({ href, ...props }) => {
const found = source.getPageByHref(href ?? '', {
dir: page.file.dirname,
dir: path.dirname(page.path),
});
if (!found) return <Link href={href} {...props} />;
+22 -83
View File
@@ -1,94 +1,38 @@
import type { MetaData, PageData } from '@/source/types';
import {
type FileInfo,
type FolderInfo,
parseFilePath,
parseFolderPath,
} from './path';
import { joinPath, splitPath } from '@/utils/path';
export interface MetaFile<Data extends MetaData = MetaData> {
file: FileInfo;
format: 'meta';
data: Data;
}
export interface PageFile<Data extends PageData = PageData> {
file: FileInfo;
format: 'page';
data: {
slugs: string[];
data: Data;
};
}
type File = MetaFile | PageFile;
export interface Folder<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
file: FolderInfo;
children: (MetaFile<Meta> | PageFile<Page> | Folder<Page, Meta>)[];
}
import { dirname } from './path';
import { splitPath } from '@/utils/path';
/**
* A virtual file system that solves inconsistent behaviours
*
* Some source providers may not provide the full file structure, this will cause inconsistent outputs for page builder and other transformers
* In memory file system.
*/
export class Storage {
export class FileSystem<File> {
files = new Map<string, File>();
folders = new Map<string, Folder>();
private rootFolder: Folder = {
file: parseFolderPath(''),
children: [],
};
folders = new Map<string, string[]>();
constructor() {
this.folders.set('', this.rootFolder);
this.folders.set('', []);
}
read(path: string): File | undefined {
return this.files.get(path);
}
/**
* @param path - flattened path
* @param format - file format
* get the direct children of folder (in virtual file path)
*/
read<F extends File['format']>(
path: string,
format: F,
): Extract<File, { format: F }> | undefined {
return this.files.get(`${path}.${format}`) as Extract<File, { format: F }>;
}
readDir(path: string): Folder | undefined {
readDir(path: string): string[] | undefined {
return this.folders.get(path);
}
root(): Folder {
return this.rootFolder;
write(path: string, file: File): void {
const dir = dirname(path);
this.makeDir(dir);
this.readDir(dir)?.push(path);
this.files.set(path, file);
}
write<F extends File['format']>(
path: string,
format: F,
data: Extract<File, { format: F }>['data'],
): void {
const node = {
format,
file: parseFilePath(path),
data,
} as File;
this.makeDir(node.file.dirname);
this.readDir(node.file.dirname)?.children.push(node);
this.files.set(
joinPath(node.file.dirname, `${node.file.name}.${node.format}`),
node,
);
}
list(): File[] {
return Array.from(this.files.values());
getFiles(): string[] {
return Array.from(this.files.keys());
}
makeDir(path: string): void {
@@ -98,13 +42,8 @@ export class Storage {
const segment = segments.slice(0, i + 1).join('/');
if (this.folders.has(segment)) continue;
const folder: Folder = {
file: parseFolderPath(segment),
children: [],
};
this.folders.set(folder.file.path, folder);
this.readDir(folder.file.dirname)?.children.push(folder);
this.folders.set(segment, []);
this.readDir(dirname(segment))?.push(path);
}
}
}
+4 -2
View File
@@ -1,12 +1,14 @@
export * from './page-tree-builder';
export * from './loader';
export * as FileSystem from './file-system';
export { FileSystem } from './file-system';
export {
loadFiles,
type PageFile,
type MetaFile,
type LoadOptions,
type ContentStorage,
type Transformer,
type VirtualFile,
} from './load-files';
export type * from './types';
export {
+42 -35
View File
@@ -1,10 +1,10 @@
import { type MetaData, type PageData } from '@/source/types';
import { parseFilePath, type FileInfo, normalizePath } from './path';
import { Storage } from './file-system';
import { FileSystem } from './file-system';
import { slash, splitPath } from '@/utils/path';
import { VirtualFile } from '@/source/loader';
export interface LoadOptions {
transformers?: Transformer[];
getSlugs: (info: FileInfo) => string[];
}
export interface I18nLoadOptions extends LoadOptions {
@@ -15,50 +15,43 @@ export interface I18nLoadOptions extends LoadOptions {
};
}
export interface VirtualFile {
/**
* Relative path
*
* @example `docs/page.mdx`
*/
path: string;
type: 'page' | 'meta';
export type ContentStorage = FileSystem<MetaFile | PageFile>;
/**
* Specified Slugs for page
*/
slugs?: string[];
data: unknown;
export interface MetaFile<Data extends MetaData = MetaData> {
path: string;
absolutePath: string;
format: 'meta';
data: Data;
}
export interface PageFile<Data extends PageData = PageData> {
path: string;
absolutePath: string;
format: 'page';
slugs: string[];
data: Data;
}
export type Transformer = (context: {
storage: Storage;
storage: ContentStorage;
options: LoadOptions;
}) => void;
// Virtual files -> Virtual Storage -> Transformers -> Result
export function loadFiles<O extends LoadOptions>(
export function loadFiles(
files: VirtualFile[],
options: O,
): Storage {
buildFile: (file: VirtualFile) => MetaFile | PageFile,
options: LoadOptions,
): ContentStorage {
const { transformers = [] } = options;
const storage = new Storage();
const storage: ContentStorage = new FileSystem();
for (const file of files) {
const parsedPath = normalizePath(file.path);
if (file.type === 'page') {
const slugs = file.slugs ?? options.getSlugs(parseFilePath(parsedPath));
storage.write(parsedPath, file.type, {
slugs,
data: file.data as PageData,
});
}
if (file.type === 'meta') {
storage.write(parsedPath, file.type, file.data as MetaData);
}
storage.write(parsedPath, buildFile(file));
}
for (const transformer of transformers) {
@@ -73,10 +66,11 @@ export function loadFiles<O extends LoadOptions>(
export function loadFilesI18n(
files: VirtualFile[],
buildFile: (file: VirtualFile) => MetaFile | PageFile,
options: I18nLoadOptions,
): Record<string, Storage> {
): Record<string, ContentStorage> {
const parser = options.i18n.parser === 'dir' ? dirParser : dotParser;
const storages: Record<string, Storage> = {};
const storages: Record<string, ContentStorage> = {};
for (const lang of options.i18n.languages) {
storages[lang] = loadFiles(
@@ -92,6 +86,7 @@ export function loadFilesI18n(
return [];
}),
buildFile,
options,
);
}
@@ -123,3 +118,15 @@ function dotParser(path: string): [string, string?] {
return [path];
}
/**
* @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('/');
}
+104 -36
View File
@@ -1,19 +1,19 @@
import type * as PageTree from '@/server/page-tree';
import type { I18nConfig } from '@/i18n';
import {
type ContentStorage,
loadFiles,
loadFilesI18n,
type LoadOptions,
type MetaFile,
type PageFile,
type Transformer,
type VirtualFile,
} from './load-files';
import type { MetaData, PageData, UrlFn } from './types';
import {
type BaseOptions as BasePageTreeBuilderOptions,
type BuildPageTreeOptions,
createPageTreeBuilder,
} from './page-tree-builder';
import { type FileInfo } from './path';
import type { MetaFile, PageFile, Storage } from './file-system';
import { type FileInfo, parseFilePath } from './path';
import { joinPath } from '@/utils/path';
export interface LoaderConfig {
@@ -32,8 +32,8 @@ export interface LoaderOptions<
> {
baseUrl: string;
icon?: NonNullable<BasePageTreeBuilderOptions['resolveIcon']>;
slugs?: LoadOptions['getSlugs'];
icon?: NonNullable<BuildPageTreeOptions['resolveIcon']>;
slugs?: (info: FileInfo) => string[];
url?: UrlFn;
source: Source<T>;
@@ -42,7 +42,9 @@ export interface LoaderOptions<
/**
* Additional options for page tree builder
*/
pageTree?: Partial<BasePageTreeBuilderOptions<T['pageData'], T['metaData']>>;
pageTree?: Partial<
Omit<BuildPageTreeOptions<T['pageData'], T['metaData']>, 'storage'>
>;
/**
* Configure i18n
@@ -58,11 +60,50 @@ export interface Source<Config extends SourceConfig> {
files: VirtualFile[] | (() => VirtualFile[]);
}
export interface Page<Data = PageData> {
interface SharedFileInfo {
/**
* Virtualized file path
* Virtualized file path (parsed)
*
* @deprecated Use `path` instead.
*/
file: FileInfo;
/**
* Virtualized file path (relative to content directory)
*
* @example `docs/page.mdx`
*/
path: string;
/**
* Absolute path of the file
*/
absolutePath: string;
}
export interface VirtualFile {
/**
* Virtualized path (relative to content directory)
*
* @example `docs/page.mdx`
*/
path: string;
/**
* Absolute path of the file
*/
absolutePath?: string;
type: 'page' | 'meta';
/**
* Specified Slugs for page
*/
slugs?: string[];
data: unknown;
}
export interface Page<Data = PageData> extends SharedFileInfo {
slugs: string[];
url: string;
data: Data;
@@ -70,11 +111,7 @@ export interface Page<Data = PageData> {
locale?: string | undefined;
}
export interface Meta<Data = MetaData> {
/**
* Virtualized file path
*/
file: FileInfo;
export interface Meta<Data = MetaData> extends SharedFileInfo {
data: Data;
}
@@ -153,7 +190,7 @@ export interface LoaderOutput<Config extends LoaderConfig> {
}
function indexPages(
storages: Record<string, Storage>,
storages: Record<string, ContentStorage>,
getUrl: UrlFn,
i18n?: I18nConfig,
): {
@@ -166,7 +203,9 @@ function indexPages(
const map = new Map<string, Page>();
const fileMapped = new WeakMap<object, Page | Meta>();
for (const item of storages[defaultLanguage].list()) {
for (const filePath of storages[defaultLanguage].getFiles()) {
const item = storages[defaultLanguage].read(filePath)!;
if (item.format === 'meta') {
fileMapped.set(item, fileToMeta(item));
}
@@ -177,12 +216,15 @@ function indexPages(
map.set(`${defaultLanguage}.${page.slugs.join('/')}`, page);
if (!i18n) continue;
const path = joinPath(item.file.dirname, item.file.name);
for (const lang of i18n.languages) {
if (lang === defaultLanguage) continue;
const localizedItem = storages[lang].read(path, 'page');
const localizedPage = fileToPage(localizedItem ?? item, getUrl, lang);
const localizedItem = storages[lang].read(filePath);
const localizedPage = fileToPage(
localizedItem?.format === 'page' ? localizedItem : item,
getUrl,
lang,
);
if (localizedItem) {
fileMapped.set(localizedItem, localizedPage);
@@ -266,20 +308,36 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
const files =
typeof source.files === 'function' ? source.files() : source.files;
function buildFile(file: VirtualFile): MetaFile | PageFile {
if (file.type === 'page') {
return {
format: 'page',
path: file.path,
slugs: file.slugs ?? slugsFn(parseFilePath(file.path)),
data: file.data as PageData,
absolutePath: file.absolutePath ?? '',
};
}
return {
format: 'meta',
path: file.path,
absolutePath: file.absolutePath ?? '',
data: file.data as MetaData,
};
}
const storages = i18n
? loadFilesI18n(files, {
? loadFilesI18n(files, buildFile, {
...options,
i18n: {
...i18n,
parser: i18n.parser ?? 'dot',
},
transformers: options.transformers,
getSlugs: slugsFn,
})
: {
'': loadFiles(files, {
transformers: options.transformers,
getSlugs: slugsFn,
}),
'': loadFiles(files, buildFile, options),
};
const walker = indexPages(storages, getUrl, i18n);
@@ -359,15 +417,17 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
const ref = node.$ref?.metaFile;
if (!ref) return;
const file = storages[language].read(ref, 'meta');
if (file) return walker.getResultFromFile(file) as Meta;
const file = storages[language].read(ref);
if (file?.format === 'meta')
return walker.getResultFromFile(file) as Meta;
},
getNodePage(node, language = defaultLanguage) {
const ref = node.$ref?.file;
if (!ref) return;
const file = storages[language].read(ref, 'page');
if (file) return walker.getResultFromFile(file) as Page;
const file = storages[language].read(ref);
if (file?.format === 'page')
return walker.getResultFromFile(file) as Page;
},
getPageTree(locale) {
if (options.i18n) {
@@ -398,7 +458,11 @@ function createOutput(options: LoaderOptions): LoaderOutput<LoaderConfig> {
function fileToMeta<Data = MetaData>(file: MetaFile): Meta<Data> {
return {
file: file.file,
path: file.path,
absolutePath: file.absolutePath,
get file() {
return parseFilePath(file.path);
},
data: file.data as Data,
};
}
@@ -409,10 +473,14 @@ function fileToPage<Data = PageData>(
locale?: string,
): Page<Data> {
return {
file: file.file,
url: getUrl(file.data.slugs, locale),
slugs: file.data.slugs,
data: file.data.data as Data,
get file() {
return parseFilePath(file.path);
},
absolutePath: file.absolutePath,
path: file.path,
url: getUrl(file.slugs, locale),
slugs: file.slugs,
data: file.data as Data,
locale,
};
}
+137 -121
View File
@@ -1,22 +1,24 @@
import type { ReactElement } from 'react';
import type { I18nConfig } from '@/i18n';
import type * as PageTree from '../server/page-tree';
import type { Folder, MetaFile, PageFile, Storage } from './file-system';
import { joinPath } from '@/utils/path';
import type { MetaData, PageData, UrlFn } from './types';
import type { ContentStorage, MetaFile, PageFile } from '@/source/load-files';
import { basename, extname } from '@/source/path';
interface PageTreeBuilderContext {
storage: Storage;
storage: ContentStorage;
resolveName: (name: string, format: 'meta' | 'page') => string;
locale?: string;
localeStorage?: Storage;
localeStorage?: ContentStorage;
builder: PageTreeBuilder;
options: BaseOptions;
getUrl: UrlFn;
}
export interface BaseOptions<
interface BaseOptions<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> {
@@ -30,7 +32,9 @@ export interface BaseOptions<
attachFile?: (node: PageTree.Item, file?: PageFile<Page>) => PageTree.Item;
attachFolder?: (
node: PageTree.Folder,
folder: Folder<Page, Meta>,
folder: {
children: (PageFile<Page> | MetaFile<Meta>)[];
},
meta?: MetaFile<Meta>,
) => PageTree.Folder;
attachSeparator?: (node: PageTree.Separator) => PageTree.Separator;
@@ -38,12 +42,18 @@ export interface BaseOptions<
resolveIcon?: (icon: string | undefined) => ReactElement | undefined;
}
export interface BuildPageTreeOptions extends BaseOptions {
storage: Storage;
export interface BuildPageTreeOptions<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends BaseOptions<Page, Meta> {
storage: ContentStorage;
}
export interface BuildPageTreeOptionsWithI18n extends BaseOptions {
storages: Record<string, Storage>;
export interface BuildPageTreeOptionsWithI18n<
Page extends PageData = PageData,
Meta extends MetaData = MetaData,
> extends BaseOptions<Page, Meta> {
storages: Record<string, ContentStorage>;
i18n: I18nConfig;
}
@@ -66,72 +76,47 @@ const restReversed = 'z...a' as const;
const extractPrefix = '...';
const excludePrefix = '!';
function isPageFile(node: Folder | PageFile | MetaFile): node is PageFile {
return 'data' in node && node.format === 'page';
}
/**
* @param nodes - All nodes to be built (in default locale)
* @param ctx - Context
* @param filter - filter nodes to be built
* @param reversed - return in reversed order
* @returns Nodes with specified locale in context (sorted)
*/
function buildAll(
nodes: (Folder | MetaFile | PageFile)[],
paths: string[],
ctx: PageTreeBuilderContext,
filter?: (node: Folder | MetaFile | PageFile) => boolean,
filter?: (path: string) => boolean,
reversed = false,
): PageTree.Node[] {
const { localeStorage } = ctx;
const output: PageTree.Node[] = [];
const sortedPaths = (filter ? paths.filter(filter) : [...paths]).sort(
(a, b) => a.localeCompare(b) * (reversed ? -1 : 1),
);
for (const node of [...nodes].sort((a, b) => {
let result;
if (isPageFile(a) && 'children' in b) result = -1;
else if ('children' in a && isPageFile(b)) result = 1;
else result = a.file.name.localeCompare(b.file.name);
for (const path of sortedPaths) {
const fileNode = buildFileNode(path, ctx);
if (!fileNode) continue;
return reversed ? result * -1 : result;
})) {
if (filter && !filter(node)) continue;
if (basename(path, extname(path)) === 'index') output.unshift(fileNode);
else output.push(fileNode);
}
if (isPageFile(node)) {
const localized = localeStorage?.read(
joinPath(node.file.dirname, node.file.name),
'page',
);
const treeNode = buildFileNode(localized ?? node, ctx);
if (node.file.name === 'index') {
output.unshift(treeNode);
continue;
}
output.push(treeNode);
} else if ('children' in node) {
output.push(buildFolderNode(node, false, ctx));
}
for (const dir of sortedPaths) {
const dirNode = buildFolderNode(dir, false, ctx);
if (dirNode) output.push(dirNode);
}
return output;
}
function resolveFolderItem(
folder: Folder,
folderPath: string,
item: string,
ctx: PageTreeBuilderContext,
idx: number,
addedNodePaths: Set<string>,
restNodePaths: Set<string>,
): PageTree.Node[] | typeof rest | typeof restReversed {
if (item === rest || item === restReversed) return item;
const { options, storage, localeStorage } = ctx;
const { options, resolveName } = ctx;
let match = separator.exec(item);
if (match?.groups) {
const node: PageTree.Separator = {
$id: `${folder.file.path}#${idx}`,
$id: `${folderPath}#${idx}`,
type: 'separator',
icon: options.resolveIcon?.(match.groups.icon),
name: match.groups.name,
@@ -167,96 +152,91 @@ function resolveFolderItem(
filename = item.slice(extractPrefix.length);
}
const path = joinPath(folder.file.path, filename);
const path = resolveName(joinPath(folderPath, filename), 'page');
restNodePaths.delete(path);
const itemNode =
storage.readDir(path) ??
localeStorage?.read(path, 'page') ??
storage.read(path, 'page');
if (!itemNode) return [];
addedNodePaths.add(itemNode.file.path);
if (isExcept) return [];
if ('children' in itemNode) {
const node = buildFolderNode(itemNode, false, ctx);
return isExtract ? node.children : [node];
const dirNode = buildFolderNode(path, false, ctx);
if (dirNode) {
return isExtract ? dirNode.children : [dirNode];
}
return [buildFileNode(itemNode, ctx)];
const fileNode = buildFileNode(path, ctx);
return fileNode ? [fileNode] : [];
}
function buildFolderNode(
folder: Folder,
folderPath: string,
isGlobalRoot: boolean,
ctx: PageTreeBuilderContext,
): PageTree.Folder {
const { storage, localeStorage, options } = ctx;
const metaPath = joinPath(folder.file.path, 'meta');
const meta =
localeStorage?.read(metaPath, 'meta') ?? storage.read(metaPath, 'meta');
): PageTree.Folder | undefined {
const { storage, localeStorage, options, resolveName } = ctx;
const files = storage.readDir(folderPath);
if (!files) return;
const indexPath = joinPath(folder.file.path, 'index');
const indexFile =
localeStorage?.read(indexPath, 'page') ?? storage.read(indexPath, 'page');
const metaPath = resolveName(joinPath(folderPath, 'meta'), 'meta');
const indexPath = resolveName(joinPath(folderPath, 'index'), 'page');
let meta = localeStorage?.read(metaPath) ?? storage.read(metaPath);
if (meta?.format !== 'meta') {
meta = undefined;
}
const isRoot = meta?.data.root ?? isGlobalRoot;
let index: PageTree.Item | undefined;
let indexDisabled = false;
let children: PageTree.Node[];
if (!meta?.data.pages) {
if (indexFile) index = buildFileNode(indexFile, ctx);
children = buildAll(folder.children, ctx, (node) => {
return node.file.name !== 'index' || isRoot;
});
children = buildAll(files, ctx, (file) => isRoot || file !== indexPath);
} else {
const addedNodePaths = new Set<string>();
const restItems = new Set<string>(files);
const resolved = meta.data.pages.flatMap<
PageTree.Node | typeof rest | typeof restReversed
>((item, i) => resolveFolderItem(folder, item, ctx, i, addedNodePaths));
>((item, i) => resolveFolderItem(folderPath, item, ctx, i, restItems));
if (indexFile && (isRoot || !addedNodePaths.has(indexFile.file.path))) {
index = buildFileNode(indexFile, ctx);
// disable folder index if it is used in `pages`
if (!isRoot && !restItems.has(indexPath)) {
indexDisabled = true;
}
for (let i = 0; i < resolved.length; i++) {
if (resolved[i] === rest || resolved[i] === restReversed) {
resolved.splice(
i,
1,
...buildAll(
folder.children,
ctx,
(node) => {
if (node.file.name === 'index' && !isRoot) return false;
return !addedNodePaths.has(node.file.path);
},
resolved[i] === restReversed,
),
);
break;
}
const item = resolved[i];
if (item !== rest && item !== restReversed) continue;
const items = buildAll(
files,
ctx,
// index files are not included in ... unless it's a root folder
(file) => (file !== indexPath || isRoot) && restItems.has(file),
item === restReversed,
);
resolved.splice(i, 1, ...items);
break;
}
children = resolved as PageTree.Node[];
}
const index = !indexDisabled ? buildFileNode(indexPath, ctx) : undefined;
let name = meta?.data.title ?? index?.name;
if (!name) {
const folderName = basename(folderPath);
name = pathToName(group.exec(folderName)?.[1] ?? folderName);
}
const node: PageTree.Folder = {
type: 'folder',
name:
meta?.data.title ??
index?.name ??
// resolve folder groups like (group_name)
pathToName(group.exec(folder.file.name)?.[1] ?? folder.file.name),
name,
icon: options.resolveIcon?.(meta?.data.icon) ?? index?.icon,
root: meta?.data.root,
defaultOpen: meta?.data.defaultOpen,
description: meta?.data.description,
index,
children,
$id: folder.file.path,
$id: folderPath,
$ref:
!options.noRef && meta
? {
@@ -265,26 +245,37 @@ function buildFolderNode(
: undefined,
};
return options.attachFolder?.(node, folder, meta) ?? node;
return (
options.attachFolder?.(
node,
{
get children() {
return files.flatMap((file) => storage.read(file) ?? []);
},
},
meta,
) ?? node
);
}
function buildFileNode(
page: PageFile,
{ options, getUrl, locale }: PageTreeBuilderContext,
): PageTree.Item {
const file = page.file;
const { slugs, data } = page.data;
path: string,
{ options, getUrl, storage, localeStorage, locale }: PageTreeBuilderContext,
): PageTree.Item | undefined {
const page = localeStorage?.read(path) ?? storage.read(path);
if (page?.format !== 'page') return;
const { title, description, icon } = page.data;
const item: PageTree.Item = {
$id: file.path,
$id: path,
type: 'page',
name: data.title ?? pathToName(file.name),
description: data.description,
icon: options.resolveIcon?.(data.icon),
url: getUrl(slugs, locale),
name: title ?? pathToName(basename(path, extname(path))),
description,
icon: options.resolveIcon?.(icon),
url: getUrl(page.slugs, locale),
$ref: !options.noRef
? {
file: joinPath(file.dirname, file.name),
file: path,
}
: undefined,
};
@@ -293,8 +284,7 @@ function buildFileNode(
}
function build(ctx: PageTreeBuilderContext): PageTree.Root {
const root = ctx.storage.root();
const folder = buildFolderNode(root, true, ctx);
const folder = buildFolderNode('', true, ctx)!;
return {
$id: ctx.locale ?? 'root',
@@ -304,24 +294,50 @@ function build(ctx: PageTreeBuilderContext): PageTree.Root {
}
export function createPageTreeBuilder(getUrl: UrlFn): PageTreeBuilder {
function createFlattenPathResolver(storage: ContentStorage) {
const map = new Map<string, string>();
const files = storage.getFiles();
for (const file of files) {
const content = storage.read(file)!;
const flattenPath = file.substring(0, file.length - extname(file).length);
map.set(flattenPath + '.' + content.format, file);
}
return (name: string, format: string) => {
return map.get(name + '.' + format);
};
}
return {
build(options) {
const resolve = createFlattenPathResolver(options.storage);
return build({
options,
builder: this,
storage: options.storage,
getUrl,
resolveName(name, format) {
return resolve(name, format) ?? name;
},
});
},
buildI18n({ i18n, ...options }) {
const storage = options.storages[i18n.defaultLanguage];
const resolve = createFlattenPathResolver(storage);
const entries = i18n.languages.map<[string, PageTree.Root]>((lang) => {
const tree = build({
options,
getUrl,
builder: this,
locale: lang,
storage: options.storages[i18n.defaultLanguage],
storage,
localeStorage: options.storages[lang],
resolveName(name, format) {
return resolve(name, format) ?? name;
},
});
return [lang, tree];
+32 -31
View File
@@ -1,5 +1,3 @@
import { slash, splitPath } from '@/utils/path';
export interface FileInfo {
/**
* File path without extension
@@ -42,49 +40,52 @@ export interface FolderInfo {
dirname: string;
}
export function parseFilePath(path: string): FileInfo {
const segments = splitPath(slash(path));
export function basename(path: string, ext?: string): string {
const idx = path.lastIndexOf('/');
const dirname = segments.slice(0, -1).join('/');
let name = segments.at(-1) ?? '';
let ext = '';
return path.substring(
idx === -1 ? 0 : idx + 1,
ext ? path.length - ext.length : path.length,
);
}
export function extname(path: string): string {
const dotIdx = path.lastIndexOf('.');
const dotIdx = name.lastIndexOf('.');
if (dotIdx !== -1) {
ext = name.substring(dotIdx);
name = name.substring(0, dotIdx);
return path.substring(dotIdx);
}
return '';
}
export function dirname(path: string): string {
return path.split('/').slice(0, -1).join('/');
}
export function parseFilePath(path: string): FileInfo {
const ext = extname(path);
const name = basename(path, ext);
const dir = dirname(path);
return {
dirname,
dirname: dir,
name,
path: segments.join('/'),
ext,
path,
get flattenedPath() {
return [dirname, name].filter((p) => p.length > 0).join('/');
return [dir, name].filter((p) => p.length > 0).join('/');
},
};
}
/**
* @deprecated use `dirname` and `basename` directly.
*/
export function parseFolderPath(path: string): FolderInfo {
const segments = splitPath(slash(path));
const base = segments.at(-1) ?? '';
return {
dirname: segments.slice(0, -1).join('/'),
name: base,
path: segments.join('/'),
dirname: dirname(path),
name: basename(path),
path,
};
}
/**
* @param path - Relative path
* @returns Normalized path, with no trailing/leading slashes
* @throws Throws error if path starts with `./` or `../`
*/
export 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('/');
}
-18
View File
@@ -77,16 +77,6 @@ describe('Path utilities', () => {
"path": "nested/test.01.mdx",
}
`);
expect(parseFilePath('nested\\test.cn.mdx')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test.cn",
"name": "test.cn",
"path": "nested/test.cn.mdx",
}
`);
});
test('parse folder path', () => {
@@ -105,14 +95,6 @@ describe('Path utilities', () => {
"path": "nested/nested",
}
`);
expect(parseFolderPath('nested\\nested')).toMatchInlineSnapshot(`
{
"dirname": "nested",
"name": "nested",
"path": "nested/nested",
}
`);
});
test('resolve paths', () => {
+19 -17
View File
@@ -306,7 +306,7 @@ test('Internationalized Routing', () => {
{
"$id": "test.mdx",
"$ref": {
"file": "test",
"file": "test.mdx",
},
"name": "Hello",
"type": "page",
@@ -318,7 +318,7 @@ test('Internationalized Routing', () => {
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test",
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
@@ -341,7 +341,7 @@ test('Internationalized Routing', () => {
{
"$id": "test.mdx",
"$ref": {
"file": "test",
"file": "test.mdx",
},
"name": "Hello Chinese",
"type": "page",
@@ -349,21 +349,18 @@ test('Internationalized Routing', () => {
},
{
"$id": "nested",
"$ref": {
"metaFile": "nested/meta",
},
"children": [
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test",
"file": "nested/test.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"name": "Nested",
"type": "folder",
},
],
@@ -377,6 +374,7 @@ test('Internationalized Routing', () => {
"language": "cn",
"pages": [
{
"absolutePath": "",
"data": {
"title": "Hello Chinese",
},
@@ -388,12 +386,14 @@ test('Internationalized Routing', () => {
"path": "test.mdx",
},
"locale": "cn",
"path": "test.mdx",
"slugs": [
"test",
],
"url": "/cn/test",
},
{
"absolutePath": "",
"data": {
"title": "Nested Page Chinese",
},
@@ -405,6 +405,7 @@ test('Internationalized Routing', () => {
"path": "nested/test.mdx",
},
"locale": "cn",
"path": "nested/test.mdx",
"slugs": [
"nested",
"test",
@@ -417,6 +418,7 @@ test('Internationalized Routing', () => {
"language": "en",
"pages": [
{
"absolutePath": "",
"data": {
"title": "Hello",
},
@@ -428,12 +430,14 @@ test('Internationalized Routing', () => {
"path": "test.mdx",
},
"locale": "en",
"path": "test.mdx",
"slugs": [
"test",
],
"url": "/en/test",
},
{
"absolutePath": "",
"data": {
"title": "Nested Page",
},
@@ -445,6 +449,7 @@ test('Internationalized Routing', () => {
"path": "nested/test.mdx",
},
"locale": "en",
"path": "nested/test.mdx",
"slugs": [
"nested",
"test",
@@ -521,7 +526,7 @@ test('Internationalized Routing: Hide Prefix', () => {
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"name": "Nested",
"type": "folder",
},
],
@@ -719,7 +724,7 @@ test('Internationalized Routing: dir', () => {
{
"$id": "test.mdx",
"$ref": {
"file": "test",
"file": "test.mdx",
},
"name": "Hello",
"type": "page",
@@ -731,7 +736,7 @@ test('Internationalized Routing: dir', () => {
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test",
"file": "nested/test.mdx",
},
"name": "Nested Page",
"type": "page",
@@ -754,7 +759,7 @@ test('Internationalized Routing: dir', () => {
{
"$id": "test.mdx",
"$ref": {
"file": "test",
"file": "test.mdx",
},
"name": "Hello Chinese",
"type": "page",
@@ -762,21 +767,18 @@ test('Internationalized Routing: dir', () => {
},
{
"$id": "nested",
"$ref": {
"metaFile": "nested/meta",
},
"children": [
{
"$id": "nested/test.mdx",
"$ref": {
"file": "nested/test",
"file": "nested/test.mdx",
},
"name": "Nested Page Chinese",
"type": "page",
"url": "/cn/nested/test",
},
],
"name": "Nested Chinese",
"name": "Nested",
"type": "folder",
},
],
-144
View File
@@ -1,144 +0,0 @@
import { Storage } from '@/source/file-system';
import { describe, expect, test } from 'vitest';
import { VirtualFile, loadFiles } from '@/source/load-files';
import { getSlugs } from '@/source/loader';
describe('Virtual Storage', () => {
const storage = new Storage();
test('Writing', () => {
storage.write('test.mdx', 'page', {
slugs: [],
data: {
title: 'Hello',
},
});
storage.write('meta.json', 'meta', {});
expect(storage.list().length).toBe(2);
});
test('Reading', () => {
expect(storage.read('test', 'page')).toBeDefined();
expect(storage.read('meta', 'meta')).toBeDefined();
});
test('Nested Directories', () => {
storage.write('dir1/dir2/meta.json', 'meta', {});
expect(storage.readDir('dir1')).toBeDefined();
expect(storage.readDir('dir1/dir2')).toBeDefined();
expect(storage.read('dir1/dir2/meta', 'meta')).toBeDefined();
});
});
const demoFiles: VirtualFile[] = [
{
type: 'page',
path: 'test.mdx',
data: {
title: 'Hello',
},
},
{
type: 'page',
path: '/nested/test.mdx',
data: {
title: 'Nested Page',
},
},
{
type: 'page',
path: '/nested/nested/test.mdx',
data: {
title: 'Nested Nested Page',
},
},
];
test('Building File Graph', () => {
const storage = loadFiles(demoFiles, {
rootDir: '',
getSlugs,
});
expect(storage.root().children).toMatchInlineSnapshot(`
[
{
"data": {
"data": {
"title": "Hello",
},
"slugs": [
"test",
],
},
"file": {
"dirname": "",
"ext": ".mdx",
"flattenedPath": "test",
"name": "test",
"path": "test.mdx",
},
"format": "page",
},
{
"children": [
{
"data": {
"data": {
"title": "Nested Page",
},
"slugs": [
"nested",
"test",
],
},
"file": {
"dirname": "nested",
"ext": ".mdx",
"flattenedPath": "nested/test",
"name": "test",
"path": "nested/test.mdx",
},
"format": "page",
},
{
"children": [
{
"data": {
"data": {
"title": "Nested Nested Page",
},
"slugs": [
"nested",
"nested",
"test",
],
},
"file": {
"dirname": "nested/nested",
"ext": ".mdx",
"flattenedPath": "nested/nested/test",
"name": "test",
"path": "nested/nested/test.mdx",
},
"format": "page",
},
],
"file": {
"dirname": "nested",
"name": "nested",
"path": "nested/nested",
},
},
],
"file": {
"dirname": "",
"name": "nested",
"path": "nested",
},
},
]
`);
});
+2
View File
@@ -81,6 +81,7 @@ export function resolveFiles({ docs, meta }: ResolveOptions): VirtualFile[] {
for (const entry of docs) {
outputs.push({
type: 'page',
absolutePath: entry._file.absolutePath,
path: entry._file.path,
data: entry,
});
@@ -89,6 +90,7 @@ export function resolveFiles({ docs, meta }: ResolveOptions): VirtualFile[] {
for (const entry of meta) {
outputs.push({
type: 'meta',
absolutePath: entry._file.absolutePath,
path: entry._file.path,
data: entry,
});
+6 -3
View File
@@ -1,6 +1,6 @@
import type { FileSystem } from 'fumadocs-core/source';
import { MethodLabel } from '@/ui/components/method-label';
import type { PageTree } from 'fumadocs-core/server';
import type { PageFile } from 'fumadocs-core/source';
/**
* Source API Integration
@@ -9,10 +9,13 @@ import type { PageTree } from 'fumadocs-core/server';
*/
export const attachFile = (
node: PageTree.Item,
file: FileSystem.PageFile | undefined,
file: PageFile | undefined,
): PageTree.Item => {
if (!file) return node;
const data = file.data.data as object;
let data = file.data as object;
// backward compatible with older versions with `_openapi` is located in `data.data`
if ('data' in data && data.data && typeof data === 'object') data = data.data;
let method: string | undefined;
if ('_openapi' in data && typeof data._openapi === 'object') {
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: ['packages/*'],
},
});
-1
View File
@@ -1 +0,0 @@
export default ['packages/*/vitest.config.ts'];