mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-07-27 04:31:57 +00:00
Core & MDX & OpenAPI: Add absolutePath and path properties to pages, mark file as deprecated
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'fumadocs-openapi': patch
|
||||
'fumadocs-core': patch
|
||||
'fumadocs-mdx': patch
|
||||
---
|
||||
|
||||
Add `absolutePath` and `path` properties to pages, mark `file` as deprecated
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: ['packages/*'],
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export default ['packages/*/vitest.config.ts'];
|
||||
Reference in New Issue
Block a user